diff --git a/CHANGELOG.md b/CHANGELOG.md index 26590ca2..e7991b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### 修复 +- Windows 安装预配不再把 Node 工作目录选到残留的嵌套 `openxyos\\openxyos`:顶层已有 `backend-dist/server.js` 与 `dist/index.html` 时必须用 live 根。嵌套 cwd 会让 `node backend-dist/server.js` 立刻退出且 stdout/stderr 为空,安装空等 90s 后以退出码 12 失败。`start-sidecar.ps1` / 预配 / 桌面 Go / Python 启动路径统一按此选择 cwd;每次启动截断 `start.log`;Shell.Application 若未刷新日志则改走 explorer / Start-Process,Node fail-fast 不再伪装成 livez 超时。 +- Windows 安装详情不再把 openXYOS 预配的 UTF-8 Node/PowerShell 控制台(`[Error] POST /api/auth`、`[seed]`、中文 Server/WebSocket 状态)按系统 ANSI/GBK 打成乱码。NSIS 只用 `nsExec::Exec` 等退出码,详情页只显示本地化步骤结果;完整日志写入 `%LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log` / `start.log`(UTF-8)。解压前停止并等待旧的 FreeOS openXYOS Node 退出;livez 通过即成功,不把启动期 auth 日志当失败。 - 组织页互生长通道两端都落到可选用状态:推送到 openXYOS 时员工/人才写成列表页能看到的 `active` / `internal` / `available`;从 openXYOS 回流时即使没有技能字段也会登记为同事,技能/插件/MCP 写入 `org-skills` / `org-plugins` / `org-mcps`,并把聊天专家挂到当前用户(修复 `user_id=NULL` 导致「专家」页看不见)。导入后跳到「专家」页,推送/循环后关闭管理抽屉并刷新预览。 - 组织页内嵌浏览器补上后退 / 前进 / 刷新;openXYOS 首页卡通助手改为打包进前端资源(并保留 `/assets/xyai-mascot.webp` 回退),不再显示破碎图片。 - 启动 FreeOS 时就会拉起本机 openXYOS 前后端(桌面 `startOrgSidecar` + 主机 `ensure_sidecar`),不必先进入组织页;登录不再因为 3780 未监听而 `Failed to fetch`。 diff --git a/desktop/README.md b/desktop/README.md index 12eff993..74118968 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -29,13 +29,21 @@ Same precedence as the FreeOS CLI/server: `$INSTDIR\openxyos` as a sealed copy; the `openxyos-runtime` zip and folder are deleted after a successful provision), starts FE/BE at medium integrity (Node inherits `CORS_ORIGIN` / `NODE_ENV`; stdout/stderr - go to `start.log`), and writes `.install-ready` only after - `http://127.0.0.1:3780/api/health/livez` is healthy. Before extract it + go to UTF-8 `start.log`), and writes `.install-ready` only after + `http://127.0.0.1:3780/api/health/livez` is healthy. The Setup detail list + shows localized step results only (`nsExec::Exec`, not `ExecToLog`); full + PowerShell/Node console stays in `%LOCALAPPDATA%\FreeOS\openxyos\provision.log` + and `start.log` so UTF-8 Chinese is not misread as GBK. Before extract it stops only FreeOS openXYOS Node (`start.pid` / live and `$INSTDIR\openxyos` - paths), unpacks into a LocalAppData temp dir, logs tar stderr to - `provision.log`, and treats a non-zero tar as success when the layout - heals. If Node dies before livez, the provisioner exits 10 with a - `start.log` excerpt instead of waiting out a livez timeout. A failed + paths) and waits until that owned process is gone. After extract+heal, Node + starts with cwd at the live root when `backend-dist/server.js` and + `dist/index.html` exist there (a leftover nested `openxyos\openxyos` tree + must not win — that cwd exits immediately with an empty console). It unpacks into a LocalAppData + temp dir, logs tar stderr to `provision.log`, and treats a non-zero tar as + success when the layout heals. Transient `[Error] POST /api/auth` / `[seed]` + lines in `start.log` are not a provision failure when livez is healthy. If + Node dies before livez, the provisioner exits 10 with a `start.log` excerpt + in `provision.log` instead of waiting out a livez timeout. A failed subprocess is a failed install step (retry the provisioner). There is no Windows logon autostart: when FreeOS starts later, it brings local openXYOS up with it. Organization embeds `http://127.0.0.1:3780` directly. @@ -53,11 +61,13 @@ with `makensis -INPUTCHARSET UTF8` from a UTF-8 BOM `project.nsi`. The install log is no longer only `FreeOS.exe` + shortcuts. A healthy package also copies `openxyos-runtime.zip` plus the provisioner (`provision-openxyos.ps1` / `.cmd`, `start-sidecar.ps1` / `.cmd`). Setup **nsExecs that provisioner as -a child process** and waits for exit 0. The child extracts with Windows +a child process** (`nsExec::Exec`, stdout discarded) and waits for exit 0. +The child extracts with Windows `tar.exe` (quoted paths, so `Program Files` works) into a LocalAppData temp dir, then heals that tree into `%LOCALAPPDATA%\FreeOS\openxyos` after -stopping only the FreeOS openXYOS Node that would lock those files. tar -stdout/stderr go to `provision.log`. A non-zero tar is not exit 3 when the +stopping only the FreeOS openXYOS Node that would lock those files and +waiting until it is gone. tar +stdout/stderr go to UTF-8 `provision.log`, not the NSIS detail list. A non-zero tar is not exit 3 when the layout is complete after heal. It then starts Node at medium integrity (IShellDispatch2 — never High-IL Node from the elevated installer), waits for livez, and writes `.install-ready` only when healthy. `$INSTDIR\openxyos` diff --git a/desktop/src/build/windows/nsis/project.nsi b/desktop/src/build/windows/nsis/project.nsi index 088ef219..0238abda 100644 --- a/desktop/src/build/windows/nsis/project.nsi +++ b/desktop/src/build/windows/nsis/project.nsi @@ -58,6 +58,8 @@ LangString OPENXYOS_COPY_ZIP ${LANG_SIMPCHINESE} "复制 openXYOS 运行包( LangString OPENXYOS_COPY_ZIP ${LANG_ENGLISH} "Copy the openXYOS runtime payload and provisioner subprocess" LangString OPENXYOS_PROVISION ${LANG_SIMPCHINESE} "正在以子进程部署 openXYOS(解压、启动前后端、等待健康检查)" LangString OPENXYOS_PROVISION ${LANG_ENGLISH} "Running the openXYOS provisioner subprocess (extract, start FE/BE, wait for health)" +LangString OPENXYOS_PROVISION_DETAIL ${LANG_SIMPCHINESE} "完整日志:%LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log(UTF-8);此处只显示步骤结果" +LangString OPENXYOS_PROVISION_DETAIL ${LANG_ENGLISH} "Full log: %LOCALAPPDATA%\\FreeOS\\openxyos\\provision.log (UTF-8); this list shows step results only" LangString OPENXYOS_PROVISION_CODE ${LANG_SIMPCHINESE} "openXYOS 预配子进程退出码 " LangString OPENXYOS_PROVISION_CODE ${LANG_ENGLISH} "openXYOS provisioner subprocess exit code " LangString OPENXYOS_PROVISION_OK ${LANG_SIMPCHINESE} "本机 openXYOS 已就绪(http://127.0.0.1:3780 健康检查通过)" diff --git a/desktop/src/build/windows/nsis/provision-openxyos.cmd b/desktop/src/build/windows/nsis/provision-openxyos.cmd index b4d7449b..1a93b9f0 100644 --- a/desktop/src/build/windows/nsis/provision-openxyos.cmd +++ b/desktop/src/build/windows/nsis/provision-openxyos.cmd @@ -1,6 +1,9 @@ @echo off setlocal EnableExtensions -rem NSIS nsExec/ExecWait target. Real provisioner is provision-openxyos.ps1. +rem NSIS nsExec target. Real provisioner is provision-openxyos.ps1. +rem Hide PowerShell/Node stdout from the installer detail list: UTF-8 +rem Chinese would mojibake as system ANSI (GBK on Chinese Windows). +rem Full logs: %LOCALAPPDATA%\FreeOS\openxyos\provision.log and start.log rem Pass-through only. No cmd labels. -"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%~dp0provision-openxyos.ps1" %* +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%~dp0provision-openxyos.ps1" %* >nul 2>&1 exit /b %ERRORLEVEL% diff --git a/desktop/src/build/windows/nsis/provision-openxyos.ps1 b/desktop/src/build/windows/nsis/provision-openxyos.ps1 index af49b20f..a0199387 100644 --- a/desktop/src/build/windows/nsis/provision-openxyos.ps1 +++ b/desktop/src/build/windows/nsis/provision-openxyos.ps1 @@ -3,6 +3,10 @@ # extract to a LocalAppData temp dir, heal into $LiveDir, start once at # medium IL, wait livez, write .install-ready. # Exit 0 only when http://127.0.0.1:3780/api/health/livez is healthy. +# Full logs stay UTF-8 in %LOCALAPPDATA%\FreeOS\openxyos\provision.log and +# start.log. Host/NSIS detail gets OEM-safe (ACP/GBK) short status only — +# never raw Node console (seed / auth POST / WebSocket). Transient +# [Error] POST /api/auth lines are not a provision failure when livez is up. # On success, deletes $InstallDir\openxyos-runtime (folder + archive) and a # sibling openxyos-runtime under the live parent. Never deletes $LiveDir or # $InstallDir\openxyos. Failed runs leave staging in place for debugging. @@ -32,10 +36,71 @@ $ErrorActionPreference = 'Continue' $script:ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $script:LogPath = Join-Path $LiveDir 'provision.log' -function Write-ProvLog { +# NSIS ExecToLog / cmd.exe treat child stdout as the system ANSI code page +# (CP936/GBK on Chinese Windows). PowerShell 5.1 Write-Host is UTF-16/UTF-8, +# so Chinese Node lines become mojibake. Emit ACP bytes for the console; +# keep the on-disk log UTF-8. +function Get-NsisOemEncoding { + $cp = 0 + try { + $cp = [Console]::OutputEncoding.CodePage + } catch { + $cp = 0 + } + if ($cp -le 0) { + try { + $cp = [int](Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage' -Name ACP).ACP + } catch { + $cp = 0 + } + } + if ($cp -le 0) { + return [System.Text.Encoding]::Default + } + try { + return [System.Text.Encoding]::GetEncoding($cp) + } catch { + return [System.Text.Encoding]::Default + } +} + +function ConvertTo-NsisOemText { + param([string]$Text) + if ([string]::IsNullOrEmpty($Text)) { return '' } + $oem = Get-NsisOemEncoding + $roundtrip = $oem.GetString($oem.GetBytes($Text)) + if ($roundtrip -eq $Text) { return $Text } + $chars = New-Object System.Text.StringBuilder + foreach ($ch in $Text.ToCharArray()) { + if ([int]$ch -lt 128) { [void]$chars.Append($ch) } + } + return $chars.ToString().Trim() +} + +function Write-ProvHost { param([string]$Message) + if ([string]::IsNullOrEmpty($Message)) { return } + $safe = ConvertTo-NsisOemText $Message + if ([string]::IsNullOrEmpty($safe)) { return } + try { + $oem = Get-NsisOemEncoding + $payload = $oem.GetBytes($safe + [Environment]::NewLine) + $stdout = [Console]::OpenStandardOutput() + $stdout.Write($payload, 0, $payload.Length) + } catch { + Write-Host $safe + } +} + +function Write-ProvLog { + param( + [string]$Message, + [switch]$Quiet + ) $line = '{0} {1}' -f (Get-Date -Format o), $Message - Write-Host $line + if (-not $Quiet) { + Write-ProvHost $Message + } try { $dir = Split-Path -Parent $script:LogPath if ($dir -and -not (Test-Path -LiteralPath $dir)) { @@ -46,6 +111,17 @@ function Write-ProvLog { } } +function Test-OpenXYOSTransientConsoleLine { + param([string]$Line) + if ([string]::IsNullOrEmpty($Line)) { return $false } + if ($Line -match '(?i)POST\s+/api/auth') { return $true } + if ($Line -match '(?i)\[seed\]') { return $true } + if ($Line -match '(?i)node still running pid=') { return $true } + if ($Line -match '(?i)WebSocket:') { return $true } + if ($Line -match '(?i)Server:\s+https?://') { return $true } + return $false +} + function Test-OpenXYOSLayout { param([string]$Root) $node = Join-Path $Root 'node\node.exe' @@ -55,6 +131,26 @@ function Test-OpenXYOSLayout { return (Test-Path -LiteralPath $nested) -or (Test-Path -LiteralPath $flat) } +function Test-OpenXYOSAppReady { + param([string]$Root) + if (-not $Root) { return $false } + $fe = Join-Path $Root 'dist\index.html' + $be = Join-Path $Root 'backend-dist\server.js' + return (Test-Path -LiteralPath $fe) -and (Test-Path -LiteralPath $be) +} + +# Prefer the healed live root. Nested openxyos\openxyos cwd makes Node +# exit immediately with empty stdout/stderr (livez then times out). +function Resolve-OpenXYOSAppDir { + param([string]$Root) + if (Test-OpenXYOSAppReady $Root) { return $Root } + $nested = Join-Path $Root 'openxyos' + if (Test-OpenXYOSAppReady $nested) { return $nested } + $nestedFe = Join-Path $nested 'dist\index.html' + if (Test-Path -LiteralPath $nestedFe) { return $nested } + return $Root +} + function Copy-OpenXYOSTree { param([string]$Src, [string]$Dest) New-Item -ItemType Directory -Force -Path $Dest | Out-Null @@ -165,10 +261,22 @@ function Repair-OpenXYOSMigrations { } function Test-OpenXYOSLivez { + param([switch]$LogError) + $url = 'http://127.0.0.1:3780/api/health/livez' try { - $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri 'http://127.0.0.1:3780/api/health/livez' + $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 2 -Uri $url return ($r.StatusCode -lt 500) } catch { + if ($LogError) { + $detail = $_.Exception.Message + try { + if ($_.Exception.Response) { + $detail = '{0} HTTP {1}' -f $url, [int]$_.Exception.Response.StatusCode + } + } catch { + } + Write-ProvLog "livez probe failed: $detail" + } return $false } } @@ -194,14 +302,14 @@ function Get-StartLogExcerpt { function Write-StartLogExcerpt { $excerpt = Get-StartLogExcerpt if (-not $excerpt) { - Write-ProvLog 'start.log empty or missing (Node produced no stdout/stderr)' + Write-ProvLog -Quiet 'start.log empty or missing (Node produced no stdout/stderr)' return } - Write-ProvLog '--- start.log excerpt ---' + Write-ProvLog -Quiet '--- start.log excerpt (UTF-8 file only; not shown in NSIS) ---' foreach ($line in ($excerpt -split "`n")) { - Write-ProvLog $line + Write-ProvLog -Quiet $line } - Write-ProvLog '--- end start.log ---' + Write-ProvLog -Quiet '--- end start.log ---' } function Test-OpenXYOSPortListen { @@ -244,17 +352,59 @@ function Test-OpenXYOSNodeAlive { return $false } +function Get-StartLogStamp { + $p = Join-Path $LiveDir 'start.log' + if (-not (Test-Path -LiteralPath $p)) { return $null } + return (Get-Item -LiteralPath $p).LastWriteTimeUtc +} + +function Test-StartLogShowsFailFast { + $p = Join-Path $LiveDir 'start.log' + if (-not (Test-Path -LiteralPath $p)) { return $false } + $text = Get-Content -LiteralPath $p -Raw -ErrorAction SilentlyContinue + if (-not $text) { return $false } + return ($text -match 'node exited .+ \(fail-fast\)') +} + +function Wait-OpenXYOSStartEvidence { + param($BeforeUtc, [int]$Seconds = 12) + for ($i = 0; $i -lt $Seconds; $i++) { + if (Test-OpenXYOSLivez) { return $true } + $nid = Read-OpenXYOSPidFile $LiveDir + if ($nid -gt 0) { + $p = Get-Process -Id $nid -ErrorAction SilentlyContinue + if ($p -and -not $p.HasExited) { return $true } + } + $log = Join-Path $LiveDir 'start.log' + if (Test-Path -LiteralPath $log) { + $stamp = (Get-Item -LiteralPath $log).LastWriteTimeUtc + if ($null -eq $BeforeUtc -or $stamp -gt $BeforeUtc) { return $true } + } + Start-Sleep -Seconds 1 + } + return $false +} + function Wait-OpenXYOSLivez { param([int]$Seconds) + # Success is livez only. Transient Node console (auth probe, seed banner, + # websocket status) is startup noise — not a provision failure. for ($i = 0; $i -lt $Seconds; $i++) { if (Test-OpenXYOSLivez) { return $true } + if (Test-StartLogShowsFailFast) { + Write-ProvLog 'Node exited fail-fast during livez wait (see start.log cwd)' + Write-StartLogExcerpt + exit 10 + } if ($i -ge 8 -and -not (Test-OpenXYOSLivez) -and -not (Test-OpenXYOSPortListen) -and -not (Test-OpenXYOSNodeAlive)) { Write-ProvLog 'Node is not running and 3780 is not listening during livez wait' + $null = Test-OpenXYOSLivez -LogError Write-StartLogExcerpt exit 10 } Start-Sleep -Seconds 1 } + $null = Test-OpenXYOSLivez -LogError return $false } @@ -265,27 +415,60 @@ function Start-OpenXYOSUnelevated { Write-ProvLog "start helper missing: $helper" return $false } + $app = Resolve-OpenXYOSAppDir $Dir + Write-ProvLog "start cwd=$app (live=$Dir)" $pwsh = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $arg = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$helper`"" + $before = Get-StartLogStamp try { $shell = New-Object -ComObject Shell.Application $shell.ShellExecute($pwsh, $arg, $Dir, 'open', 0) Write-ProvLog 'Started FE/BE via Shell.Application (IShellDispatch2 / medium IL)' - return $true + if (Wait-OpenXYOSStartEvidence $before 12) { + if (Test-StartLogShowsFailFast) { + Write-ProvLog 'start helper ran but Node exited fail-fast' + return $false + } + return $true + } + Write-ProvLog 'Shell.Application produced no fresh start.log/pid/livez' } catch { Write-ProvLog "Shell.Application failed: $($_.Exception.Message)" } $cmd = Join-Path $Dir 'start-sidecar.cmd' $explorer = Join-Path $env:WINDIR 'explorer.exe' if ((Test-Path -LiteralPath $cmd) -and (Test-Path -LiteralPath $explorer)) { + $before = Get-StartLogStamp try { Start-Process -FilePath $explorer -ArgumentList "`"$cmd`"" Write-ProvLog 'Started FE/BE via explorer.exe (medium IL fallback)' - return $true + if (Wait-OpenXYOSStartEvidence $before 12) { + if (Test-StartLogShowsFailFast) { + Write-ProvLog 'explorer start helper ran but Node exited fail-fast' + return $false + } + return $true + } + Write-ProvLog 'explorer fallback produced no fresh start.log/pid/livez' } catch { Write-ProvLog "explorer fallback failed: $($_.Exception.Message)" } } + $before = Get-StartLogStamp + try { + Start-Process -FilePath $pwsh -ArgumentList $arg -WorkingDirectory $Dir -WindowStyle Hidden + Write-ProvLog 'Started FE/BE via Start-Process (direct powershell fallback)' + if (Wait-OpenXYOSStartEvidence $before 12) { + if (Test-StartLogShowsFailFast) { + Write-ProvLog 'direct start helper ran but Node exited fail-fast' + return $false + } + return $true + } + Write-ProvLog 'direct Start-Process produced no fresh start.log/pid/livez' + } catch { + Write-ProvLog "direct Start-Process failed: $($_.Exception.Message)" + } return $false } @@ -433,21 +616,39 @@ function Stop-OpenXYOSNode { } } +function Wait-OpenXYOSOwnedNodeGone { + param([int]$TimeoutMs = 8000) + $deadline = [datetime]::UtcNow.AddMilliseconds($TimeoutMs) + do { + if (-not (Test-OpenXYOSOwnNode)) { return $true } + Start-Sleep -Milliseconds 250 + } while ([datetime]::UtcNow -lt $deadline) + return (-not (Test-OpenXYOSOwnNode)) +} + function Stop-OpenXYOSLockedProcesses { Write-ProvLog 'Stopping FreeOS openXYOS processes before extract' $any = $false foreach ($root in (Get-OpenXYOSLockRoots)) { if (-not $root) { continue } - if (-not (Test-Path -LiteralPath $root)) { continue } $any = $true Stop-OpenXYOSNode $root } if (-not $any) { - Write-ProvLog 'No FreeOS openXYOS processes to stop before extract' + Write-ProvLog 'No FreeOS openXYOS lock roots to stop before extract' + return } - for ($i = 0; $i -lt 8; $i++) { - if (-not (Test-OpenXYOSOwnLivez)) { break } - Start-Sleep -Milliseconds 250 + if (Wait-OpenXYOSOwnedNodeGone) { + Write-ProvLog 'FreeOS openXYOS processes stopped before extract' + return + } + Write-ProvLog 'Owned FreeOS openXYOS node still running after stop; retrying stop' + foreach ($root in (Get-OpenXYOSLockRoots)) { + if (-not $root) { continue } + Stop-OpenXYOSNode $root + } + if (-not (Wait-OpenXYOSOwnedNodeGone 5000)) { + Write-ProvLog 'Owned node still present after second stop (extract may fail if files are locked)' } } @@ -486,7 +687,7 @@ function Invoke-TarExtract { if ($null -eq $code) { $code = 0 } foreach ($row in @($output)) { $text = "$row" - if ($text) { Write-ProvLog "tar: $text" } + if ($text) { Write-ProvLog -Quiet "tar: $text" } } if ($code -ne 0) { Write-ProvLog "tar exit $code" @@ -705,6 +906,8 @@ if ($env:USERNAME) { } Install-StartHelpers $LiveDir +$appDir = Resolve-OpenXYOSAppDir $LiveDir +Write-ProvLog "canonical openXYOS cwd=$appDir (prefer top-level when backend-dist+dist exist)" if (Test-OpenXYOSOwnLivez) { Write-ProvLog 'livez already healthy after extract' diff --git a/desktop/src/build/windows/nsis/start-sidecar.ps1 b/desktop/src/build/windows/nsis/start-sidecar.ps1 index df953425..81cb9563 100644 --- a/desktop/src/build/windows/nsis/start-sidecar.ps1 +++ b/desktop/src/build/windows/nsis/start-sidecar.ps1 @@ -3,6 +3,10 @@ # Always stop a previous live-dir Node first so a day-old process cannot # keep a stale CORS_ORIGIN (missing http://127.0.0.1:3780) and blank the iframe. # Never start Node while this process is elevated (High IL). +# Node stdout/stderr go only to start.log / start.out.log / start.err.log +# (UTF-8). Do not Write-Host those lines — NSIS detail treats child stdout +# as system ANSI (GBK on Chinese Windows) and would show mojibake plus +# transient [Error] POST /api/auth / [seed] noise. $ErrorActionPreference = 'Continue' $live = Split-Path -Parent $MyInvocation.MyCommand.Path $livez = 'http://127.0.0.1:3780/api/health/livez' @@ -56,6 +60,27 @@ function Find-OpenXYOSBundleRoot { return $null } +function Test-OpenXYOSAppReady { + param([string]$Root) + if (-not $Root) { return $false } + $fe = Join-Path $Root 'dist\index.html' + $be = Join-Path $Root 'backend-dist\server.js' + return (Test-Path -LiteralPath $fe) -and (Test-Path -LiteralPath $be) +} + +# After extract+heal both a flat live root and leftover nested openxyos\ +# can exist. Nested cwd makes `node backend-dist/server.js` exit immediately +# with empty stdout/stderr. Prefer the healed top-level tree. +function Resolve-OpenXYOSAppDir { + param([string]$Root) + if (Test-OpenXYOSAppReady $Root) { return $Root } + $nested = Join-Path $Root 'openxyos' + if (Test-OpenXYOSAppReady $nested) { return $nested } + $nestedFe = Join-Path $nested 'dist\index.html' + if (Test-Path -LiteralPath $nestedFe) { return $nested } + return $Root +} + function Repair-OpenXYOSLayout { param([string]$Root) $nestedFe = Join-Path $Root 'openxyos\dist\index.html' @@ -126,13 +151,49 @@ function Test-IsElevated { return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } +$startLog = Join-Path $live 'start.log' +$startOut = Join-Path $live 'start.out.log' +$startErr = Join-Path $live 'start.err.log' +$pidFile = Join-Path $live 'start.pid' + +function Reset-OpenXYOSStartLogs { + foreach ($old in @($startLog, $startOut, $startErr)) { + if (Test-Path -LiteralPath $old) { + Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue + } + } + @( + '{0} start-sidecar begin live={1}' -f (Get-Date -Format o), $live + ) | Set-Content -LiteralPath $startLog -Encoding UTF8 +} + if (Test-IsElevated) { $pwsh = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $arg = "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`"" + $before = $null + if (Test-Path -LiteralPath $startLog) { + $before = (Get-Item -LiteralPath $startLog).LastWriteTimeUtc + } try { $shell = New-Object -ComObject Shell.Application $shell.ShellExecute($pwsh, $arg, $live, 'open', 0) - exit 0 + for ($i = 0; $i -lt 10; $i++) { + Start-Sleep -Milliseconds 400 + if (Test-Livez) { exit 0 } + if (Test-Path -LiteralPath $pidFile) { + $nid = 0 + $raw = Get-Content -LiteralPath $pidFile -Raw -ErrorAction SilentlyContinue + if ($null -ne $raw -and [int]::TryParse($raw.Trim(), [ref]$nid) -and $nid -gt 0) { + $p = Get-Process -Id $nid -ErrorAction SilentlyContinue + if ($p -and -not $p.HasExited) { exit 0 } + } + } + if (Test-Path -LiteralPath $startLog) { + $stamp = (Get-Item -LiteralPath $startLog).LastWriteTimeUtc + if ($null -eq $before -or $stamp -gt $before) { exit 0 } + } + } + Add-Content -LiteralPath $startLog -Value 'unelevate produced no fresh start.log/pid/livez; continuing in this process' -Encoding UTF8 } catch { Write-Error "cannot unelevate start-sidecar.ps1; refusing High-IL Node" exit 10 @@ -140,12 +201,7 @@ if (Test-IsElevated) { } $node = Join-Path $live 'node\node.exe' -$app = Join-Path $live 'openxyos' -if (-not (Test-Path -LiteralPath (Join-Path $app 'dist\index.html'))) { - if (Test-Path -LiteralPath (Join-Path $live 'dist\index.html')) { - $app = $live - } -} +$app = Resolve-OpenXYOSAppDir $live if (-not (Test-Path -LiteralPath $node)) { exit 6 } # $HOME / $home is a read-only automatic variable in Windows PowerShell. @@ -206,27 +262,25 @@ if (Test-Path -LiteralPath $compiled) { # Launch with ProcessStartInfo.UseShellExecute = $false and copy env onto # EnvironmentVariables. Start-Process -FilePath $node with Redirect*/-NoNewWindow # is the file-log path (those switches also force UseShellExecute=$false). -$startLog = Join-Path $live 'start.log' -$startOut = Join-Path $live 'start.out.log' -$startErr = Join-Path $live 'start.err.log' -$pidFile = Join-Path $live 'start.pid' +Reset-OpenXYOSStartLogs +$layout = 'nested' +if ($app -eq $live) { $layout = 'top-level' } @( '{0} launching node={1}' -f (Get-Date -Format o), $node "cwd=$app" + "layout=$layout" + "live=$live" "argv=$($argv -join ' ')" "NODE_ENV=$($env:NODE_ENV)" "PORT=$($env:PORT)" + "JWT_SECRET set=$([bool]$env:JWT_SECRET)" + "COOKIE_SECRET set=$([bool]$env:COOKIE_SECRET)" "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)) { - if (Test-Path -LiteralPath $old) { - Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue - } -} $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.UseShellExecute = $false @@ -304,9 +358,15 @@ if ($proc.WaitForExit(5000)) { } catch { } } - Add-Content -LiteralPath $startLog -Value "node exited $($proc.ExitCode) (fail-fast)" -Encoding UTF8 + Add-Content -LiteralPath $startLog -Value "node exited $($proc.ExitCode) (fail-fast) cwd=$app layout=$layout" -Encoding UTF8 + if (-not (Test-Path -LiteralPath $startOut) -and -not (Test-Path -LiteralPath $startErr)) { + Add-Content -LiteralPath $startLog -Value 'node stdout/stderr empty (typical of nested openxyos\openxyos cwd crash)' -Encoding UTF8 + } + if (($app -ne $live) -and (Test-OpenXYOSAppReady $live)) { + Add-Content -LiteralPath $startLog -Value "nested cwd crashed; top-level $live has backend-dist/server.js + dist/index.html" -Encoding UTF8 + } exit 10 } Merge-NodeLogs -Add-Content -LiteralPath $startLog -Value "node still running pid=$($proc.Id)" -Encoding UTF8 +Add-Content -LiteralPath $startLog -Value "node still running pid=$($proc.Id) cwd=$app layout=$layout" -Encoding UTF8 exit 0 diff --git a/desktop/src/build/windows/nsis/wails_tools.nsh b/desktop/src/build/windows/nsis/wails_tools.nsh index f0a17b27..9e3aba3f 100644 --- a/desktop/src/build/windows/nsis/wails_tools.nsh +++ b/desktop/src/build/windows/nsis/wails_tools.nsh @@ -146,7 +146,10 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" !macroend # After the FreeOS shell files are copied, run the shipped openXYOS -# provisioner as a child process (nsExec). The parent waits for exit 0. +# provisioner as a child process (nsExec::Exec, not ExecToLog). The +# parent waits for exit 0. Child stdout is discarded (cmd >nul) so +# UTF-8 Node/PowerShell logs do not mojibake in the ANSI detail list. +# Localized DetailPrint LangStrings are the only NSIS-visible progress. # The child extracts with tar.exe, starts FE/BE at medium IL, waits for # livez, and writes .install-ready only when healthy. Do not FileWrite # goto-label .cmd scripts here. Do not register logon autostart. @@ -164,13 +167,14 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" File "start-sidecar.ps1" File "start-sidecar.cmd" DetailPrint "$(OPENXYOS_PROVISION)" + DetailPrint "$(OPENXYOS_PROVISION_DETAIL)" StrCpy $R4 '"$INSTDIR\provision-openxyos.cmd" -ZipPath "$INSTDIR\openxyos-runtime.zip" -InstallDir "$INSTDIR" -LiveDir "$R6\FreeOS\openxyos"' - nsExec::ExecToLog $R4 + nsExec::Exec $R4 Pop $0 DetailPrint "$(OPENXYOS_PROVISION_CODE)$0" ${If} $0 == 12 DetailPrint "$(OPENXYOS_PROVISION_RETRY)" - nsExec::ExecToLog $R4 + nsExec::Exec $R4 Pop $0 DetailPrint "$(OPENXYOS_PROVISION_CODE)$0" ${EndIf} @@ -193,7 +197,7 @@ RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" Abort openxyosProvisionRetry: DetailPrint "$(OPENXYOS_PROVISION_RETRY)" - nsExec::ExecToLog $R4 + nsExec::Exec $R4 Pop $0 DetailPrint "$(OPENXYOS_PROVISION_CODE)$0" ${If} $0 == 0 diff --git a/desktop/src/process_sidecar.go b/desktop/src/process_sidecar.go index 62cce84b..ae72c314 100644 --- a/desktop/src/process_sidecar.go +++ b/desktop/src/process_sidecar.go @@ -41,23 +41,28 @@ func sidecarAppReady(app string) bool { if strings.TrimSpace(app) == "" { return false } - if _, err := os.Stat(filepath.Join(app, "backend", "server.ts")); err != nil { - return false - } if _, err := os.Stat(filepath.Join(app, "dist", "index.html")); err != nil { return false } - return true + if _, err := os.Stat(filepath.Join(app, "backend-dist", "server.js")); err == nil { + return true + } + if _, err := os.Stat(filepath.Join(app, "backend", "server.ts")); err == nil { + return true + } + return false } func sidecarAppAt(bundle string) string { + // Prefer the healed live root. Nested openxyos\openxyos cwd makes + // `node backend-dist/server.js` exit immediately with an empty console. + if sidecarAppReady(bundle) { + return bundle + } nested := filepath.Join(bundle, "openxyos") if sidecarAppReady(nested) { return nested } - if sidecarAppReady(bundle) { - return bundle - } return nested } diff --git a/desktop/src/process_sidecar_test.go b/desktop/src/process_sidecar_test.go index 122c7697..bfe463a6 100644 --- a/desktop/src/process_sidecar_test.go +++ b/desktop/src/process_sidecar_test.go @@ -371,3 +371,23 @@ func TestSidecarNodeArgsPrefersCompiledServer(t *testing.T) { t.Fatalf("compiled args=%v", args) } } + +func TestSidecarAppAtPrefersTopLevelWhenBothExist(t *testing.T) { + root := t.TempDir() + writeSidecarBundle(t, root) + if err := os.MkdirAll(filepath.Join(root, "backend-dist"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "backend-dist", "server.js"), []byte("ok"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "dist"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "dist", "index.html"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if got := sidecarAppAt(root); got != root { + t.Fatalf("sidecarAppAt=%q want top-level %q", got, root) + } +} diff --git a/src/octop/modules/org_os/sidecar_launch.py b/src/octop/modules/org_os/sidecar_launch.py index 2027715e..57aaa63d 100644 --- a/src/octop/modules/org_os/sidecar_launch.py +++ b/src/octop/modules/org_os/sidecar_launch.py @@ -144,7 +144,11 @@ def sidecar_bundle_dir() -> Path | None: def _app_looks_complete(app: Path) -> bool: - return (app / "backend" / "server.ts").is_file() and (app / "dist" / "index.html").is_file() + if not (app / "dist" / "index.html").is_file(): + return False + return (app / "backend-dist" / "server.js").is_file() or ( + app / "backend" / "server.ts" + ).is_file() def _bundle_looks_complete(path: Path) -> bool: @@ -217,6 +221,13 @@ def heal_openxyos_layout(root: Path) -> bool: def _sidecar_app_dir(bundled: Path) -> Path: + """Prefer the healed live root when FE+BE exist there. + + A leftover nested ``openxyos/openxyos`` tree must not win: Node started + from that cwd exits immediately with empty stdout/stderr. + """ + if _app_looks_complete(bundled): + return bundled nested = bundled / "openxyos" if _app_looks_complete(nested): return nested diff --git a/tests/unit/desktop/test_nsis_detail_encoding.py b/tests/unit/desktop/test_nsis_detail_encoding.py new file mode 100644 index 00000000..3f866763 --- /dev/null +++ b/tests/unit/desktop/test_nsis_detail_encoding.py @@ -0,0 +1,112 @@ +"""NSIS detail list is system ANSI; provisioner logs stay UTF-8 on disk.""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +NSIS = REPO / "desktop" / "src" / "build" / "windows" / "nsis" +PROVISION_PS1 = NSIS / "provision-openxyos.ps1" +PROVISION_CMD = NSIS / "provision-openxyos.cmd" +NSH = NSIS / "wails_tools.nsh" +NSI = NSIS / "project.nsi" + + +def nsis_oem_safe_text(text: str, oem: str = "gbk") -> str: + """Mirror ConvertTo-NsisOemText: keep text if OEM-roundtrippable, else ASCII.""" + if not text: + return "" + try: + roundtrip = text.encode(oem, errors="replace").decode(oem, errors="replace") + if roundtrip == text: + return text + except LookupError: + pass + return "".join(ch for ch in text if ord(ch) < 128).strip() + + +def is_transient_openxyos_console_line(line: str) -> bool: + """Mirror Test-OpenXYOSTransientConsoleLine (startup noise, not a fail).""" + lowered = line.lower() + if "post /api/auth" in lowered: + return True + if "[seed]" in lowered: + return True + if "node still running pid=" in lowered: + return True + if "websocket:" in lowered: + return True + return lowered.lstrip().startswith("server:") and "http" in lowered + + +def test_utf8_chinese_is_mojibake_when_nsis_reads_as_gbk() -> None: + text = "正在重试 openXYOS 预配子进程" + as_gbk = text.encode("utf-8").decode("gbk", errors="replace") + assert as_gbk != text + assert "\ufffd" in as_gbk or as_gbk.encode("gbk", errors="replace") != text.encode("gbk") + + +def test_oem_helper_keeps_gbk_chinese_and_strips_on_cp1252() -> None: + chinese = "本机 openXYOS 已就绪" + assert nsis_oem_safe_text(chinese, "gbk") == chinese + stripped = nsis_oem_safe_text(chinese, "cp1252") + assert "openXYOS" in stripped + assert "本机" not in stripped + assert nsis_oem_safe_text("exit code 12", "gbk") == "exit code 12" + assert nsis_oem_safe_text("exit code 12", "cp1252") == "exit code 12" + + +def test_gbk_bytes_roundtrip_is_readable_on_chinese_windows() -> None: + text = "完整日志:provision.log" + encoded = text.encode("gbk") + assert encoded.decode("gbk") == text + assert encoded != text.encode("utf-8") + + +def test_transient_console_lines_are_not_fatal() -> None: + assert is_transient_openxyos_console_line("[Error][25121468] POST /api/auth/login 401") + assert is_transient_openxyos_console_line("[seed] Demo data is disabled. See docs.") + assert is_transient_openxyos_console_line("node still running pid=53064") + assert is_transient_openxyos_console_line("WebSocket: ws://localhost:3780/ws") + assert is_transient_openxyos_console_line("Server: http://localhost:3780") + assert not is_transient_openxyos_console_line("tar exit 1") + assert not is_transient_openxyos_console_line("node\\node.exe missing after extract") + + +def test_provisioner_ships_oem_encoding_helpers() -> None: + text = PROVISION_PS1.read_text(encoding="utf-8") + assert "function Get-NsisOemEncoding" in text + assert "function ConvertTo-NsisOemText" in text + assert "function Write-ProvHost" in text + assert "OpenStandardOutput" in text + assert "[Console]::OutputEncoding.CodePage" in text + assert "Nls\\CodePage" in text + convert = text[ + text.index("function ConvertTo-NsisOemText") : text.index("function Write-ProvHost") + ] + assert "[int]$ch -lt 128" in convert + host = text[text.index("function Write-ProvHost") : text.index("function Write-ProvLog")] + assert "ConvertTo-NsisOemText" in host + log = text[ + text.index("function Write-ProvLog") : text.index( + "function Test-OpenXYOSTransientConsoleLine" + ) + ] + assert "[switch]$Quiet" in log + assert "Write-ProvHost" in log + assert "-Encoding UTF8" in log + + +def test_nsis_provision_does_not_exec_to_log() -> None: + nsh = NSH.read_text(encoding="utf-8") + provision = nsh[ + nsh.index("!macro wails.provisionOpenXYOS") : nsh.index("!macro wails.openxyosFailDetail") + ] + assert "nsExec::Exec $R4" in provision + assert "ExecToLog" not in provision + assert provision.count("nsExec::Exec $R4") == 3 + cmd = PROVISION_CMD.read_text(encoding="utf-8") + assert ">nul" in cmd + nsi = NSI.read_text(encoding="utf-8-sig") + assert "OPENXYOS_PROVISION_DETAIL" in nsi + assert "UTF-8" in nsi diff --git a/tests/unit/desktop/test_nsis_uninstall.py b/tests/unit/desktop/test_nsis_uninstall.py index 7ce5b8f1..a58c8f7b 100644 --- a/tests/unit/desktop/test_nsis_uninstall.py +++ b/tests/unit/desktop/test_nsis_uninstall.py @@ -218,7 +218,9 @@ def test_nsis_runs_openxyos_provisioner_subprocess() -> None: assert 'File "provision-openxyos.ps1"' in provision assert 'File "provision-openxyos.cmd"' in provision assert 'File "start-sidecar.ps1"' in provision - assert "nsExec::ExecToLog $R4" in provision + assert "nsExec::Exec $R4" in provision + assert "nsExec::ExecToLog $R4" not in provision + assert "OPENXYOS_PROVISION_DETAIL" in provision assert r"$INSTDIR\openxyos-runtime.zip" in provision assert r"$R6\FreeOS\openxyos" in provision # Cleanup after success lives in the provisioner, not NSIS (failed @@ -232,6 +234,8 @@ def test_nsis_runs_openxyos_provisioner_subprocess() -> None: assert "OPENXYOS_FAIL_LIVEZ" in nsh assert "start.log" in nsi assert "LangString OPENXYOS_PROVISION ${LANG_SIMPCHINESE}" in nsi + assert "LangString OPENXYOS_PROVISION_DETAIL ${LANG_SIMPCHINESE}" in nsi + assert "provision.log(UTF-8)" in nsi assert "子进程" in nsi assert "OPENXYOS_PROBE_WARN" not in nsi assert "安装将继续" not in nsi diff --git a/tests/unit/desktop/test_openxyos_provisioner.py b/tests/unit/desktop/test_openxyos_provisioner.py index b62374c3..01d16323 100644 --- a/tests/unit/desktop/test_openxyos_provisioner.py +++ b/tests/unit/desktop/test_openxyos_provisioner.py @@ -69,6 +69,9 @@ def test_provisioner_starts_medium_integrity_and_requires_livez() -> None: assert "Test-OpenXYOSNodeAlive" in text assert "Test-OpenXYOSPortListen" in text assert "exit 10" in text + assert "ConvertTo-NsisOemText" in text + assert "Write-ProvHost" in text + assert "Test-OpenXYOSTransientConsoleLine" in text def test_start_sidecar_does_not_assign_automatic_home() -> None: @@ -82,6 +85,20 @@ def test_start_sidecar_does_not_assign_automatic_home() -> None: assert not re.search(r"(?i)\$pid\s*=", text) +def resolve_openxyos_app_dir(root: Path) -> Path: + """Mirror Resolve-OpenXYOSAppDir: prefer healed top-level over nested.""" + flat_fe = root / "dist" / "index.html" + flat_be = root / "backend-dist" / "server.js" + nested = root / "openxyos" + if flat_fe.is_file() and flat_be.is_file(): + return root + if (nested / "backend-dist" / "server.js").is_file() or ( + nested / "dist" / "index.html" + ).is_file(): + return nested + return root + + def test_start_sidecar_stops_stale_node_and_heals_layout() -> None: text = START_PS1.read_text(encoding="utf-8") assert "Repair-OpenXYOSLayout" in text @@ -89,10 +106,50 @@ def test_start_sidecar_stops_stale_node_and_heals_layout() -> None: assert "start.pid" in text assert "openxyos\\dist\\index.html" in text assert "backend-dist\\server.js" in text - assert "if (Test-Livez) { exit 0 }" not in text + before_stop = text.split("Stop-OpenXYOSNode $live", 1)[0] + assert "if (Test-Livez) { exit 0 }" not in before_stop assert "day-old" in text or "stale" in text.lower() +def test_layout_picker_prefers_top_level_when_both_exist(tmp_path: Path) -> None: + live = tmp_path / "openxyos" + nested = live / "openxyos" + for folder in (live, nested): + (folder / "dist").mkdir(parents=True) + (folder / "backend-dist").mkdir(parents=True) + (folder / "dist" / "index.html").write_text("", encoding="utf-8") + (folder / "backend-dist" / "server.js").write_text("/* compiled */", encoding="utf-8") + assert resolve_openxyos_app_dir(live) == live + (live / "backend-dist" / "server.js").unlink() + assert resolve_openxyos_app_dir(live) == nested + + +def test_start_sidecar_prefers_top_level_app_dir() -> None: + start = START_PS1.read_text(encoding="utf-8") + text = PROVISION_PS1.read_text(encoding="utf-8") + for body in (start, text): + assert "function Resolve-OpenXYOSAppDir" in body + assert "function Test-OpenXYOSAppReady" in body + picker = body[ + body.index("function Resolve-OpenXYOSAppDir") : body.index( + "function Repair-OpenXYOSLayout" + ) + ] + assert "Test-OpenXYOSAppReady $Root" in picker + assert picker.index("Test-OpenXYOSAppReady $Root") < picker.index( + "Test-OpenXYOSAppReady $nested" + ) + assert ( + "Join-Path $live 'openxyos'" not in start.split("Resolve-OpenXYOSAppDir $live", 1)[1][:200] + ) + assert "layout=$layout" in start + assert "JWT_SECRET set=" in start + assert "nested openxyos\\openxyos cwd crash" in start or "nested openxyos" in start + assert "function Wait-OpenXYOSStartEvidence" in text + assert "direct powershell fallback" in text + assert "Node exited fail-fast" in 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") @@ -116,7 +173,7 @@ def test_provisioner_fail_fast_when_node_dies() -> None: # Dead Node during livez wait is exit 10, not a 90s exit 12. dead = text.index("Node is not running and 3780 is not listening") exit10 = text.index("exit 10", dead) - exit12 = text.index("exit 12") + exit12 = text.rindex("exit 12") assert exit10 < exit12 assert "LivezTimeoutSec" in text @@ -167,14 +224,15 @@ def test_provisioner_stops_owned_node_before_extract() -> None: text = PROVISION_PS1.read_text(encoding="utf-8") start = START_PS1.read_text(encoding="utf-8") stop_fn = text.index("function Stop-OpenXYOSNode") + wait_fn = text.index("function Wait-OpenXYOSOwnedNodeGone") wrap_fn = text.index("function Stop-OpenXYOSLockedProcesses") extract_fn = text.index("function Invoke-TarExtract") main = text.index("# --- main ---") stop_call = text.index("Stop-OpenXYOSLockedProcesses", main) first_extract = text.index("Invoke-TarExtract", main) - assert stop_fn < wrap_fn < extract_fn < main + assert stop_fn < wait_fn < wrap_fn < extract_fn < main assert stop_call < first_extract - node_body = text[stop_fn:wrap_fn] + node_body = text[stop_fn:wait_fn] wrap_body = text[wrap_fn:extract_fn] assert "Stop-OpenXYOSNode" in start assert "Stop-OpenXYOSNode" in wrap_body @@ -190,6 +248,9 @@ def test_provisioner_stops_owned_node_before_extract() -> None: assert "$LiveDir" in lock_body assert "Stopping FreeOS openXYOS process" in node_body assert "stop before extract" in wrap_body + assert "Wait-OpenXYOSOwnedNodeGone" in wrap_body + assert "Test-OpenXYOSOwnNode" in text[wait_fn:wrap_fn] + assert "retrying stop" in wrap_body assert "Stop-Process -Name node" not in text assert "taskkill" not in node_body.lower() # Same owned-path rule as start-sidecar: live node.exe / command line, not every Node. @@ -200,7 +261,7 @@ def test_provisioner_logs_tar_stderr() -> None: text = PROVISION_PS1.read_text(encoding="utf-8") body = text[text.index("function Invoke-TarExtract") : text.index("function Test-SamePath")] assert "2>&1" in body - assert 'Write-ProvLog "tar:' in body or 'Write-ProvLog "tar:' in body + assert 'Write-ProvLog -Quiet "tar:' in body assert "tar exit" in body assert ( "LOCALAPPDATA" @@ -265,6 +326,42 @@ def test_provisioner_idempotent_requires_own_layout_not_stray_livez() -> None: assert "if (Test-OpenXYOSLivez)" not in main[main.index("Install-StartHelpers") :] +def test_provisioner_keeps_node_console_out_of_nsis_detail() -> None: + text = PROVISION_PS1.read_text(encoding="utf-8") + cmd = PROVISION_CMD.read_text(encoding="utf-8") + excerpt = text[ + text.index("function Write-StartLogExcerpt") : text.index( + "function Test-OpenXYOSPortListen" + ) + ] + assert "Write-ProvLog -Quiet" in excerpt + assert "Write-Host" not in excerpt + assert "not shown in NSIS" in excerpt + tar_body = text[text.index("function Invoke-TarExtract") : text.index("function Test-SamePath")] + assert 'Write-ProvLog -Quiet "tar:' in tar_body + assert ">nul" in cmd + assert "2>&1" in cmd + + +def test_provisioner_ignores_transient_auth_console_noise() -> None: + text = PROVISION_PS1.read_text(encoding="utf-8") + helper = text[ + text.index("function Test-OpenXYOSTransientConsoleLine") : text.index( + "function Test-OpenXYOSLayout" + ) + ] + assert "POST\\s+/api/auth" in helper + assert "[seed]" in helper.replace("\\", "") + assert "node still running pid=" in helper + wait = text[ + text.index("function Wait-OpenXYOSLivez") : text.index("function Start-OpenXYOSUnelevated") + ] + assert "Test-OpenXYOSLivez" in wait + assert "Test-OpenXYOSTransientConsoleLine" not in wait + assert "-match" not in wait + assert "startup noise" in wait + + def test_wrappers_have_no_goto_labels() -> None: for path in (PROVISION_CMD, START_CMD): body = path.read_text(encoding="utf-8") diff --git a/tests/unit/test_sidecar_launch.py b/tests/unit/test_sidecar_launch.py index fdc1ac97..a434792c 100644 --- a/tests/unit/test_sidecar_launch.py +++ b/tests/unit/test_sidecar_launch.py @@ -9,6 +9,7 @@ from octop.modules.org_os.service import OrgModuleService from octop.modules.org_os.sidecar_launch import ( SidecarRuntime, + _sidecar_app_dir, ensure_sidecar, find_sidecar_runtime, launch_sidecar_argv, @@ -296,6 +297,20 @@ def test_launch_argv_windows_prefers_ps1(monkeypatch: pytest.MonkeyPatch, tmp_pa assert str(launcher) in argv +def test_sidecar_app_dir_prefers_top_level_when_both_exist(tmp_path: Path) -> None: + live = tmp_path / "openxyos" + nested = live / "openxyos" + for folder in (live, nested): + (folder / "dist").mkdir(parents=True) + (folder / "backend-dist").mkdir(parents=True) + (folder / "dist" / "index.html").write_text("", encoding="utf-8") + (folder / "backend-dist" / "server.js").write_text("/* compiled */", encoding="utf-8") + assert _sidecar_app_dir(live) == live + (live / "backend-dist" / "server.js").unlink() + (live / "backend" / "server.ts").unlink(missing_ok=True) + assert _sidecar_app_dir(live) == nested + + def test_heal_openxyos_layout_promotes_nested(tmp_path: Path) -> None: from octop.modules.org_os.sidecar_launch import heal_openxyos_layout