Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,不再把「启动边车」当主按钮;仅自动启动失败才显示「重试启动」和中文原因。
Expand Down
13 changes: 13 additions & 0 deletions desktop/portable/bundle-org-sidecar.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
64 changes: 64 additions & 0 deletions desktop/src/build/stage_openxyos_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
Path("openxyos") / "dist" / "index.html",
)

# initDatabase() in modules/openxyos/backend/db.ts reads these via
# path.join(__dirname, "migrations/<name>"). 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."""
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
76 changes: 76 additions & 0 deletions desktop/src/build/windows/nsis/provision-openxyos.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down
16 changes: 11 additions & 5 deletions desktop/src/build/windows/nsis/start-sidecar.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''
Expand Down Expand Up @@ -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) {
Expand All @@ -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)) {
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/desktop/test_openxyos_provisioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
77 changes: 76 additions & 1 deletion tests/unit/desktop/test_stage_openxyos_runtime.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import importlib.util
import re
import zipfile
from pathlib import Path

Expand All @@ -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")
Expand All @@ -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("<html></html>", 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:
Expand Down Expand Up @@ -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", "<html></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")
Loading