From 1ede48d5bce2a59f0251ec105114b8d4542332cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 02:07:29 +0000 Subject: [PATCH] Fix openXYOS $home clash and ship backend-dist migrations. Windows PowerShell treats $home/$HOME as read-only, so start-sidecar.ps1 was leaving SQLite under %USERPROFILE%\org-os. Use $freeosHome instead. Compiled server.js reads SQL from __dirname/migrations; include those files in openxyos-runtime.zip and heal existing live trees on provision. Co-authored-by: XYAI Labs --- CHANGELOG.md | 2 + desktop/portable/bundle-org-sidecar.sh | 13 ++++ desktop/src/build/stage_openxyos_runtime.py | 64 +++++++++++++++ .../build/windows/nsis/provision-openxyos.ps1 | 76 ++++++++++++++++++ .../src/build/windows/nsis/start-sidecar.ps1 | 16 ++-- .../unit/desktop/test_openxyos_provisioner.py | 13 ++++ .../desktop/test_stage_openxyos_runtime.py | 77 ++++++++++++++++++- 7 files changed, 255 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b298b2b7..4ec4e077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 修复 +- Windows 安装期 `start-sidecar.ps1` 不再把数据目录写到只读自动变量 `$home` / `$HOME`(赋值抛 `SessionStateUnauthorizedAccessException`,`$ErrorActionPreference = Continue` 时脚本继续跑,SQLite 会落到 `%USERPROFILE%\\org-os\\xiongyuan.db`)。改为 `$freeosHome`(默认 `%USERPROFILE%\\.freeos`)。 +- 预构建 `openxyos-runtime.zip` 现在带上 `backend-dist/migrations/*.sql`。编译后的 `server.js` 用 `__dirname/migrations` 做 `initDatabase()`,缺文件会 ENOENT(`013_audit_bundle.sql`)并让 livez 永远起不来。安装期预配也会把已有 live 目录里的 `backend/migrations` 补到 `backend-dist/migrations`。 - Windows 安装期 `start-sidecar.ps1` 不再用 PowerShell 5.1 默认的 `UseShellExecute=true` 拉起 Node(那样不会继承 `$env:CORS_ORIGIN`,生产环境 `parseOrigins` 立刻抛错,进程退出,3780 无监听,预配空等 90s 后才以退出码 12 失败)。改为 `UseShellExecute=false` 并显式写入 `ProcessStartInfo.EnvironmentVariables`,把 Node stdout/stderr 记入 `%LOCALAPPDATA%\\FreeOS\\openxyos\\start.log`;进程在 livez 前退出则预配以退出码 10 失败并附上日志摘录。 - Windows 安装把 openXYOS 预配改成 **Setup 子进程**(随包装的 `provision-openxyos.ps1`,NSIS `nsExec` 等待退出码)。子进程用 `tar.exe` 解压到 `%LOCALAPPDATA%\\FreeOS\\openxyos`、整理嵌套/扁平布局、以中完整性启动 FE/BE、通过 `livez` 后才写 `.install-ready`。失败按真实原因提示(解压 / Node / 启动 / 健康检查),不再默认归咎 3780 端口,也不再软跳过 livez。不注册 HKCU Run / 登录计划任务;之后由 FreeOS 启动时带上本机 openXYOS。组织页直接嵌入 `http://127.0.0.1:3780`,不再出现「启动边车」。 - 安装期不再在 livez 探测后 `taskkill` 边车:Setup 把 `start-sidecar.ps1` 写到 `%LOCALAPPDATA%\\FreeOS\\openxyos`,用当前用户(IShellDispatch2 / HKCU Run / 登录计划任务)常驻拉起 FE+BE,确认 `http://127.0.0.1:3780/api/health/livez` 后保持运行。组织页在 `.install-ready` 时自动嵌入本机 openXYOS,不再把「启动边车」当主按钮;仅自动启动失败才显示「重试启动」和中文原因。 diff --git a/desktop/portable/bundle-org-sidecar.sh b/desktop/portable/bundle-org-sidecar.sh index 70a76508..239a20f6 100755 --- a/desktop/portable/bundle-org-sidecar.sh +++ b/desktop/portable/bundle-org-sidecar.sh @@ -156,6 +156,13 @@ PY npx --yes esbuild backend/server.ts --bundle --platform=node --packages=external \ --outfile=backend-dist/server.js || echo "[org-sidecar] esbuild compile skipped" >&2 fi + # server.js reads SQL via path.join(__dirname, "migrations/…") — that is + # backend-dist/migrations after esbuild, not backend/migrations. + if [[ -f backend-dist/server.js && -d backend/migrations ]]; then + echo "[org-sidecar] copy backend/migrations → backend-dist/migrations" >&2 + mkdir -p backend-dist/migrations + cp -a backend/migrations/. backend-dist/migrations/ + fi ) if [[ ! -f "${work}/dist/index.html" ]]; then echo "[org-sidecar] vite build did not produce dist/index.html" >&2 @@ -275,6 +282,12 @@ copy_openxyos_runtime() { if [[ -f "${work}/package-lock.json" ]]; then cp -a "${work}/package-lock.json" "${dest}/package-lock.json" fi + # Heal: compiled server must see SQL next to server.js even if the + # build-tree copy above was skipped (cached backend-dist, etc.). + if [[ -f "${dest}/backend-dist/server.js" && -d "${dest}/backend/migrations" ]]; then + mkdir -p "${dest}/backend-dist/migrations" + cp -a "${dest}/backend/migrations/." "${dest}/backend-dist/migrations/" + fi } main() { diff --git a/desktop/src/build/stage_openxyos_runtime.py b/desktop/src/build/stage_openxyos_runtime.py index 50704264..63149ca8 100644 --- a/desktop/src/build/stage_openxyos_runtime.py +++ b/desktop/src/build/stage_openxyos_runtime.py @@ -19,6 +19,16 @@ Path("openxyos") / "dist" / "index.html", ) +# initDatabase() in modules/openxyos/backend/db.ts reads these via +# path.join(__dirname, "migrations/"). After esbuild, __dirname is +# backend-dist, so the zip must ship backend-dist/migrations/*.sql. +REQUIRED_RUNTIME_SQL = ( + "013_audit_bundle.sql", + "014_cluster_nodes.sql", + "015_tenant_industry_packages.sql", + "016_tenant_module_settings.sql", +) + def layout_ready(root: Path) -> bool: """True when *root* is a complete sidecar tree, not README-only.""" @@ -32,6 +42,52 @@ def layout_ready(root: Path) -> bool: return node_ok and fe_ok and be_ok +def _app_dirs(root: Path) -> list[Path]: + """Possible openXYOS app roots (nested ``openxyos/`` or flat).""" + seen: list[Path] = [] + for candidate in (root / "openxyos", root): + if candidate.is_dir() and candidate not in seen: + seen.append(candidate) + return seen + + +def _copy_sql_migrations(src: Path, dest: Path) -> None: + dest.mkdir(parents=True, exist_ok=True) + for item in src.iterdir(): + if item.is_file(): + shutil.copy2(item, dest / item.name) + + +def ensure_backend_dist_migrations(root: Path) -> None: + """Copy ``backend/migrations`` next to compiled ``server.js`` when needed. + + Missing SQL here is a livez blocker: Node ENOENT-opens + ``backend-dist/migrations/013_audit_bundle.sql`` during initDatabase. + """ + for app in _app_dirs(root): + compiled = app / "backend-dist" / "server.js" + if not compiled.is_file(): + continue + dest = app / "backend-dist" / "migrations" + src = app / "backend" / "migrations" + if src.is_dir(): + _copy_sql_migrations(src, dest) + missing = [name for name in REQUIRED_RUNTIME_SQL if not (dest / name).is_file()] + if missing: + raise SystemExit( + f"backend-dist/server.js present but runtime SQL missing {missing} under {app}" + ) + + +def _compiled_migration_prefixes(names: set[str]) -> list[str]: + prefixes: list[str] = [] + if "openxyos/backend-dist/server.js" in names: + prefixes.append("openxyos/backend-dist/migrations/") + if "backend-dist/server.js" in names: + prefixes.append("backend-dist/migrations/") + return prefixes + + def verify_runtime_zip(path: Path) -> None: """Fail if the zip would install as README-only (missing node or dist).""" with zipfile.ZipFile(path) as zf: @@ -41,6 +97,13 @@ def verify_runtime_zip(path: Path) -> None: be_ok = "openxyos/backend/server.ts" in names or "backend/server.ts" in names if not (node_ok and fe_ok and be_ok): raise SystemExit(f"incomplete openxyos-runtime.zip (need node + dist/index.html): {path}") + for prefix in _compiled_migration_prefixes(names): + missing = [name for name in REQUIRED_RUNTIME_SQL if f"{prefix}{name}" not in names] + if missing: + raise SystemExit( + f"incomplete openxyos-runtime.zip (need {prefix}*.sql for compiled server): " + f"missing {missing} in {path}" + ) def _bundle_root(extracted: Path) -> Path | None: @@ -84,6 +147,7 @@ def stage(source: Path, dest_zip: Path) -> Path: root = _bundle_root(extracted) if root is None: raise SystemExit(f"openXYOS sidecar (node + FE + BE) not found in {source}") + ensure_backend_dist_migrations(root) dest_zip.parent.mkdir(parents=True, exist_ok=True) if dest_zip.exists(): dest_zip.unlink() diff --git a/desktop/src/build/windows/nsis/provision-openxyos.ps1 b/desktop/src/build/windows/nsis/provision-openxyos.ps1 index 479c36ca..9d20bba9 100644 --- a/desktop/src/build/windows/nsis/provision-openxyos.ps1 +++ b/desktop/src/build/windows/nsis/provision-openxyos.ps1 @@ -90,6 +90,60 @@ function Repair-OpenXYOSLayout { return (Test-OpenXYOSLayout $Root) } +function Get-OpenXYOSAppDirs { + param([string]$Root) + return @( + (Join-Path $Root 'openxyos'), + $Root + ) +} + +function Test-OpenXYOSCompiledSql { + param([string]$Root) + $need = @( + '013_audit_bundle.sql', + '014_cluster_nodes.sql', + '015_tenant_industry_packages.sql', + '016_tenant_module_settings.sql' + ) + foreach ($app in (Get-OpenXYOSAppDirs $Root)) { + $compiled = Join-Path $app 'backend-dist\server.js' + if (-not (Test-Path -LiteralPath $compiled)) { continue } + $dest = Join-Path $app 'backend-dist\migrations' + foreach ($name in $need) { + if (-not (Test-Path -LiteralPath (Join-Path $dest $name))) { return $false } + } + return $true + } + return $true +} + +function Repair-OpenXYOSMigrations { + param([string]$Root) + $need = @( + '013_audit_bundle.sql', + '014_cluster_nodes.sql', + '015_tenant_industry_packages.sql', + '016_tenant_module_settings.sql' + ) + foreach ($app in (Get-OpenXYOSAppDirs $Root)) { + $compiled = Join-Path $app 'backend-dist\server.js' + if (-not (Test-Path -LiteralPath $compiled)) { continue } + $dest = Join-Path $app 'backend-dist\migrations' + $src = Join-Path $app 'backend\migrations' + $missing = $false + foreach ($name in $need) { + if (-not (Test-Path -LiteralPath (Join-Path $dest $name))) { $missing = $true; break } + } + if (-not $missing) { continue } + if (Test-Path -LiteralPath $src) { + Write-ProvLog "Copying $src -> $dest (compiled server needs runtime SQL)" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Copy-Item -Path (Join-Path $src '*') -Destination $dest -Force + } + } +} + function Test-OpenXYOSLivez { try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri 'http://127.0.0.1:3780/api/health/livez' @@ -313,6 +367,28 @@ if (-not ((Test-Path -LiteralPath $nestedFe) -or (Test-Path -LiteralPath $flatFe exit 7 } +# Live dir may already look complete (node + dist) from a prior install that +# shipped backend-dist/server.js without migrations. Heal SQL in place so +# initDatabase does not ENOENT on 013_audit_bundle.sql. +Repair-OpenXYOSMigrations $LiveDir +if (-not (Test-OpenXYOSCompiledSql $LiveDir)) { + foreach ($backup in @($backupRuntime, $backupOpen)) { + Repair-OpenXYOSMigrations $backup + foreach ($app in (Get-OpenXYOSAppDirs $backup)) { + $src = Join-Path $app 'backend-dist\migrations' + if (-not (Test-Path -LiteralPath $src)) { continue } + foreach ($liveApp in (Get-OpenXYOSAppDirs $LiveDir)) { + $compiled = Join-Path $liveApp 'backend-dist\server.js' + if (-not (Test-Path -LiteralPath $compiled)) { continue } + $dest = Join-Path $liveApp 'backend-dist\migrations' + Write-ProvLog "Healing runtime SQL $src -> $dest" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Copy-Item -Path (Join-Path $src '*') -Destination $dest -Force + } + } + } +} + $readme = @( 'FreeOS local openXYOS environment' "Live workdir (writable): $LiveDir" diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index 28919442..425492c4 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -44,9 +44,13 @@ if (-not (Test-Path -LiteralPath (Join-Path $app 'dist\index.html'))) { } if (-not (Test-Path -LiteralPath $node)) { exit 6 } -$home = $env:FREEOS_HOME -if (-not $home) { $home = Join-Path $env:USERPROFILE '.freeos' } -$data = Join-Path $home 'org-os' +# $HOME / $home is a read-only automatic variable in Windows PowerShell. +# Assigning it throws SessionStateUnauthorizedAccessException; with +# $ErrorActionPreference = Continue the script keeps going and $home stays +# the user profile root, so SQLite lands at %USERPROFILE%\org-os\... +$freeosHome = $env:FREEOS_HOME +if (-not $freeosHome) { $freeosHome = Join-Path $env:USERPROFILE '.freeos' } +$data = Join-Path $freeosHome 'org-os' New-Item -ItemType Directory -Force -Path $data | Out-Null $secretFile = Join-Path $data 'sidecar.env' $jwt = '' @@ -79,8 +83,8 @@ $env:JWT_SECRET = $jwt $env:COOKIE_SECRET = $cookie $env:FREEOS_INGEST_TOKEN = $ingest $env:CORS_ORIGIN = 'http://127.0.0.1:8088,http://localhost:8088,http://127.0.0.1:18900,http://localhost:18900' -$env:FREEOS_HOME = $home -$env:OCTOP_HOME = $home +$env:FREEOS_HOME = $freeosHome +$env:OCTOP_HOME = $freeosHome $env:FREEOS_ORG_SIDECAR_PORT = '3780' $compiled = Join-Path $app 'backend-dist\server.js' if (Test-Path -LiteralPath $compiled) { @@ -107,6 +111,8 @@ $pidFile = Join-Path $live 'start.pid' "PORT=$($env:PORT)" "CORS_ORIGIN=$($env:CORS_ORIGIN)" "DB_DIALECT=$($env:DB_DIALECT)" + "DATABASE_PATH=$($env:DATABASE_PATH)" + "FREEOS_HOME=$($env:FREEOS_HOME)" "AIR_GAP_MODE=$($env:AIR_GAP_MODE)" ) | Set-Content -LiteralPath $startLog -Encoding UTF8 foreach ($old in @($startOut, $startErr)) { diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index d31ddd51..fc7916ba 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -33,6 +33,8 @@ def test_provisioner_normalizes_nested_and_flat_layout() -> None: assert "dist\\index.html" in text assert "node\\node.exe" in text assert "Repair-OpenXYOSLayout" in text + assert "Repair-OpenXYOSMigrations" in text + assert "013_audit_bundle.sql" in text assert "Find-OpenXYOSBundleRoot" in text assert "org-sidecar" in text @@ -66,6 +68,17 @@ def test_provisioner_starts_medium_integrity_and_requires_livez() -> None: assert "exit 10" in text +def test_start_sidecar_does_not_assign_automatic_home() -> None: + """Windows PowerShell $HOME / $home is read-only; assigning it is a no-op.""" + text = START_PS1.read_text(encoding="utf-8") + assert "$freeosHome" in text + assert "$env:FREEOS_HOME = $freeosHome" in text + assert "$env:OCTOP_HOME = $freeosHome" in text + assert "Join-Path $freeosHome 'org-os'" in text + assert not re.search(r"(?i)\$home\s*=", text) + assert not re.search(r"(?i)\$pid\s*=", text) + + def test_start_sidecar_cors_origin_is_parseorigins_safe() -> None: """openXYOS parseOrigins requires explicit http(s) origins, no '*'.""" text = START_PS1.read_text(encoding="utf-8") diff --git a/tests/unit/desktop/test_stage_openxyos_runtime.py b/tests/unit/desktop/test_stage_openxyos_runtime.py index 1b1cfd13..f244660a 100644 --- a/tests/unit/desktop/test_stage_openxyos_runtime.py +++ b/tests/unit/desktop/test_stage_openxyos_runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import importlib.util +import re import zipfile from pathlib import Path @@ -13,8 +14,10 @@ stage_mod = importlib.util.module_from_spec(_SPEC) _SPEC.loader.exec_module(stage_mod) +DB_TS = REPO / "modules" / "openxyos" / "backend" / "db.ts" -def _write_bundle(root: Path) -> None: + +def _write_bundle(root: Path, *, compiled: bool = False, with_sql: bool = False) -> Path: node = root / "node" node.mkdir(parents=True) (node / "node.exe").write_text("node", encoding="utf-8") @@ -23,6 +26,17 @@ def _write_bundle(root: Path) -> None: (app / "backend" / "server.ts").write_text("// server", encoding="utf-8") (app / "dist").mkdir() (app / "dist" / "index.html").write_text("", encoding="utf-8") + if compiled: + compiled_dir = app / "backend-dist" + compiled_dir.mkdir(parents=True) + (compiled_dir / "server.js").write_text("/* compiled */", encoding="utf-8") + if with_sql: + migrations = app / "backend" / "migrations" + migrations.mkdir(parents=True, exist_ok=True) + for name in stage_mod.REQUIRED_RUNTIME_SQL: + (migrations / name).write_text(f"-- {name}\n", encoding="utf-8") + (migrations / "001_add_pending_reviews.sql").write_text("-- extra\n", encoding="utf-8") + return app def test_stage_openxyos_runtime_from_sidecar_dir(tmp_path: Path) -> None: @@ -54,3 +68,64 @@ def test_readme_only_layout_cannot_ship(tmp_path: Path) -> None: assert "incomplete" in str(exc) else: raise AssertionError("README-only zip must not verify") + + +def test_bundle_script_copies_migrations_next_to_compiled_server() -> None: + text = (REPO / "desktop" / "portable" / "bundle-org-sidecar.sh").read_text(encoding="utf-8") + assert "backend-dist/migrations" in text + assert "copy backend/migrations" in text + + +def test_source_tree_has_runtime_sql() -> None: + migrations = REPO / "modules" / "openxyos" / "backend" / "migrations" + for name in stage_mod.REQUIRED_RUNTIME_SQL: + assert (migrations / name).is_file(), name + + +def test_required_runtime_sql_matches_db_ts() -> None: + """Keep the staged SQL list in sync with initDatabase() readFileSync paths.""" + text = DB_TS.read_text(encoding="utf-8") + found = set(re.findall(r'migrations/([0-9]{3}_[a-z0-9_]+\.sql)"', text)) + assert found == set(stage_mod.REQUIRED_RUNTIME_SQL) + + +def test_stage_copies_backend_migrations_next_to_compiled_server(tmp_path: Path) -> None: + src = tmp_path / "org-sidecar" + _write_bundle(src, compiled=True, with_sql=True) + dest = tmp_path / "openxyos-runtime.zip" + stage_mod.stage(src, dest) + with zipfile.ZipFile(dest) as zf: + names = set(zf.namelist()) + for name in stage_mod.REQUIRED_RUNTIME_SQL: + assert f"openxyos/backend-dist/migrations/{name}" in names + assert "openxyos/backend-dist/migrations/001_add_pending_reviews.sql" in names + assert "openxyos/backend-dist/server.js" in names + stage_mod.verify_runtime_zip(dest) + + +def test_stage_fails_when_compiled_server_has_no_sql(tmp_path: Path) -> None: + src = tmp_path / "org-sidecar" + _write_bundle(src, compiled=True, with_sql=False) + dest = tmp_path / "openxyos-runtime.zip" + try: + stage_mod.stage(src, dest) + except SystemExit as exc: + assert "013_audit_bundle.sql" in str(exc) + else: + raise AssertionError("compiled server without SQL must not stage") + + +def test_verify_rejects_compiled_zip_missing_migrations(tmp_path: Path) -> None: + incomplete = tmp_path / "openxyos-runtime.zip" + with zipfile.ZipFile(incomplete, "w") as zf: + zf.writestr("node/node.exe", "node") + zf.writestr("openxyos/dist/index.html", "") + zf.writestr("openxyos/backend/server.ts", "// server") + zf.writestr("openxyos/backend-dist/server.js", "/* compiled */") + try: + stage_mod.verify_runtime_zip(incomplete) + except SystemExit as exc: + assert "migrations" in str(exc) + assert "013_audit_bundle.sql" in str(exc) + else: + raise AssertionError("compiled zip without migrations must not verify")