From 5caf360a58a536c360df1a07de22dc6e2d070870 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 13:56:10 -0700 Subject: [PATCH] fix(desktop): green Windows smoke + Linux AppImage PG packaging checks (#2138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Windows CI:** run the embedded Postgres smoke as non-admin `fusion-pg` (with profile prewarm) so elevated `windows-latest` runners stop failing with PostgreSQL’s admin-token refusal. Packaging still runs as the job user. - **Linux AppImage:** add a packaging content verifier for `main-bootstrap`, `@embedded-postgres` natives, and `omp-runtime` dist entrypoints; wire it into `release.yml`, `test-release.yml`, and the advisory **Desktop packaging** PR lane (after `electron-builder --dir`). - Fix eslint `no-undef` on bare `URL` in the verifier script (was red on #2131). ## Context Desktop packaging on Ubuntu was mostly green; Windows desktop builds and the AppImage packaging PR (#2131 lint) were the remaining red paths. The win-pg-diag pivot (run smoke as non-admin) proved green on CI; this ports that approach without removing main’s elevated-token product path for end-user “Run as administrator” cases (smoke simply does not take that path when the process is non-admin). ## Test plan - [x] `pnpm --filter @fusion/desktop exec vitest run src/__tests__/release-workflow.test.ts` - [x] `pnpm exec eslint scripts/verify-desktop-linux-pg-packaging.mjs` - [ ] Desktop packaging workflow on this PR - [ ] Desktop Windows Build (workflow_dispatch) - [ ] Confirm #2131 supersession if this lands the same AppImage checks ## Summary by CodeRabbit * **Bug Fixes** * Strengthened Linux desktop AppImage validation to confirm embedded PostgreSQL artifacts, required binaries, symlink hydration, and the expected app entrypoints are present after packaging. * Improved Windows embedded PostgreSQL smoke testing by running under a non-administrator helper user with a prewarmed profile environment. * **Tests** * Added automated packaging/release workflow verification steps (Linux and Windows) to catch embedded PostgreSQL content regressions earlier, including during artifact build and release verification. --- .github/workflows/desktop-packaging.yml | 8 + .github/workflows/desktop-windows.yml | 78 +++- .github/workflows/release.yml | 7 + .github/workflows/test-release.yml | 7 + .../src/__tests__/release-workflow.test.ts | 49 +++ scripts/verify-desktop-linux-pg-packaging.mjs | 393 ++++++++++++++++++ 6 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-desktop-linux-pg-packaging.mjs diff --git a/.github/workflows/desktop-packaging.yml b/.github/workflows/desktop-packaging.yml index 4198ba3b99..16030a7435 100644 --- a/.github/workflows/desktop-packaging.yml +++ b/.github/workflows/desktop-packaging.yml @@ -84,3 +84,11 @@ jobs: - name: Validate packageable closure (electron-builder --dir) if: steps.changes.outputs.relevant == 'true' run: pnpm --filter @fusion/desktop exec electron-builder --projectDir deploy --dir --publish never + + # FNXC:DesktopEmbeddedPostgres 2026-07-15-10:45: + # electron-builder --dir leaves linux-*-unpacked trees; assert embedded Postgres + # packaging content (main-bootstrap, natives, omp-runtime) so AppImage regressions + # fail on the advisory packaging PR lane, not only at release. + - name: Verify Linux AppImage embedded Postgres packaging + if: steps.changes.outputs.relevant == 'true' + run: node scripts/verify-desktop-linux-pg-packaging.mjs \ No newline at end of file diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml index 6dde343a17..1ea053858e 100644 --- a/.github/workflows/desktop-windows.yml +++ b/.github/workflows/desktop-windows.yml @@ -22,11 +22,87 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # FNXC:WindowsDesktopPackaging 2026-07-15-00:55: + # The embedded-PG smoke boots postgres under a non-admin helper user + # (fusion-pg). The FIRST Start-Process -Credential for that user loads its + # Windows profile hive (~10-20s), which would blow a test's 15s budget. + # Create the user and warm its profile once here, outside any test window; + # the launcher resets the user's password before each run, but the warmed + # profile persists, so every later launch is ~0.5s. + - name: Prewarm embedded-PG helper user profile + shell: pwsh + run: | + $user = "fusion-pg" + # Throwaway password for the ephemeral helper user (the launcher resets + # it before each run); generated at runtime to avoid a hardcoded literal. + $pass = "Fx9!" + ([guid]::NewGuid().ToString("N")) + "#kP" + net user $user $pass /add /y 2>&1 | Out-Null + $sec = ConvertTo-SecureString $pass -AsPlainText -Force + $cred = New-Object System.Management.Automation.PSCredential("$env:COMPUTERNAME\$user", $sec) + [void](Start-Process -FilePath cmd.exe -ArgumentList '/c','exit' -Credential $cred -Wait -WindowStyle Hidden) + Write-Host "prewarmed $user profile" + # FNXC:DesktopEmbeddedPostgres 2026-07-14-09:39: # The manual Windows installer path must boot the same embedded database # payload used by Local mode before it can publish an installer artifact. + # FNXC:WindowsDesktopPackaging 2026-07-15-02:40: + # The runner executes jobs elevated, and PostgreSQL refuses an elevated + # (admin) token. Run the WHOLE smoke AS the non-admin helper user + # (fusion-pg): the test process, its tmpdir() data dirs, AND postgres all + # run as fusion-pg, so postgres inherits a non-admin token and boots via + # the normal embedded-postgres path — no in-launcher Start-Process + # -Credential / staging / process-kill races. - name: Smoke embedded Postgres on Windows - run: pnpm --filter @fusion/core test:embedded-postgres + shell: pwsh + run: | + $user = "fusion-pg" + $pass = "Fx9!" + ([guid]::NewGuid().ToString("N")) + "#kP" + net user $user $pass /y 2>&1 | Out-Null + # FNXC:WindowsDesktopPackaging 2026-07-15-11:25: + # Full recursive grants on the workspace + pnpm store (proven green on + # win-pg-diag). Narrow grants miss pnpm resolution targets and exit 1 + # with no useful signal. Capture the bat log so failures surface. + Write-Host "granting ACL (workspace + tooling) for $user..." + icacls $env:GITHUB_WORKSPACE /grant "*S-1-5-32-545:(OI)(CI)M" /T /C 2>&1 | Out-Null + if (Test-Path D:\.pnpm-store) { + icacls D:\.pnpm-store /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C 2>&1 | Out-Null + } + icacls C:\Users\runneradmin /grant "*S-1-5-32-545:RX" /C 2>&1 | Out-Null + if (Test-Path C:\Users\runneradmin\setup-pnpm) { + icacls C:\Users\runneradmin\setup-pnpm /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C 2>&1 | Out-Null + } + $nodeDir = Split-Path (Get-Command node).Source -Parent + icacls $nodeDir /grant "*S-1-5-32-545:(OI)(CI)RX" /T /C 2>&1 | Out-Null + # Traversable HOME/TEMP for the helper user (its tmpdir() lands here). + $h = "C:\fusionpg-home" + New-Item -ItemType Directory -Force -Path "$h\tmp" | Out-Null + icacls $h /grant "*S-1-5-32-545:(OI)(CI)F" /T /C 2>&1 | Out-Null + $pnpmDir = Split-Path (Get-Command pnpm).Source -Parent + $bat = Join-Path $h "smoke.bat" + $log = Join-Path $h "smoke.log" + Set-Content -Path $bat -Encoding ASCII -Value @( + "@echo off", + "set `"USERPROFILE=$h`"", + "set `"APPDATA=$h\AppData\Roaming`"", + "set `"LOCALAPPDATA=$h\AppData\Local`"", + "set `"TEMP=$h\tmp`"", + "set `"TMP=$h\tmp`"", + "set `"PATH=$nodeDir;$pnpmDir;%PATH%`"", + "cd /d $env:GITHUB_WORKSPACE", + "call pnpm --filter @fusion/core test:embedded-postgres > `"$log`" 2>&1", + "exit /b %ERRORLEVEL%" + ) + Write-Host "running embedded-PG smoke as $user..." + $sec = ConvertTo-SecureString $pass -AsPlainText -Force + $cred = New-Object System.Management.Automation.PSCredential("$env:COMPUTERNAME\$user", $sec) + $p = Start-Process -FilePath "cmd.exe" -ArgumentList '/c',$bat -Credential $cred -Wait -PassThru -WindowStyle Hidden + if (Test-Path $log) { + Write-Host "----- smoke.log (tail) -----" + Get-Content $log -Tail 200 + } else { + Write-Host "smoke.log missing (bat may not have started)" + } + if ($p.ExitCode -ne 0) { Write-Error "embedded-PG smoke failed (exit $($p.ExitCode))"; exit 1 } # FNXC:WindowsDesktopPackaging 2026-07-01-19:45: # Mirror release.yml: build every workspace package's tsc dist (incl. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4dfad7538..ff56f2007b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -333,6 +333,13 @@ jobs: exit 1 fi + # FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20: + # AppImage filename presence is not enough — v0.60.0 shipped without + # embedded-postgres / main-bootstrap / omp-runtime. Inspect the linux-*-unpacked + # trees electron-builder leaves beside the AppImage. + - name: Verify Linux AppImage embedded Postgres packaging + run: node scripts/verify-desktop-linux-pg-packaging.mjs + - name: Sign Linux desktop artifacts if: ${{ env.LINUX_GPG_PRIVATE_KEY != '' }} env: diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 5e78a4944c..236ad45e2d 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -315,6 +315,13 @@ jobs: exit 1 fi + # FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20: + # AppImage filename presence is not enough — v0.60.0 shipped without + # embedded-postgres / main-bootstrap / omp-runtime. Inspect the linux-*-unpacked + # trees electron-builder leaves beside the AppImage. + - name: Verify Linux AppImage embedded Postgres packaging + run: node scripts/verify-desktop-linux-pg-packaging.mjs + - name: Sign Linux desktop artifacts if: ${{ env.LINUX_GPG_PRIVATE_KEY != '' }} env: diff --git a/packages/desktop/src/__tests__/release-workflow.test.ts b/packages/desktop/src/__tests__/release-workflow.test.ts index cec1aa15d2..2f3cd4ab45 100644 --- a/packages/desktop/src/__tests__/release-workflow.test.ts +++ b/packages/desktop/src/__tests__/release-workflow.test.ts @@ -59,10 +59,59 @@ describe("desktop release workflow wiring", () => { } expect(manualWindows).toContain("Smoke embedded Postgres on Windows"); expect(manualWindows).toContain("pnpm --filter @fusion/core test:embedded-postgres"); + // FNXC:WindowsDesktopPackaging 2026-07-15-10:45: + // windows-latest jobs are elevated; postgres refuses an admin token. CI must + // run the smoke as a non-admin helper (fusion-pg) so the process token is + // medium integrity and the normal embedded-postgres path works. + expect(manualWindows).toContain("fusion-pg"); + expect(manualWindows).toContain("Start-Process"); + expect(manualWindows).toContain("Prewarm embedded-PG helper user profile"); + // FNXC:WindowsDesktopPackaging 2026-07-15-11:45: + // GitHub-hosted runners provide GITHUB_WORKSPACE; never couple the helper + // user's ACLs or batch working directory to Fusion's current path on D:. + expect(manualWindows).toContain("icacls $env:GITHUB_WORKSPACE"); + expect(manualWindows).toContain('"cd /d $env:GITHUB_WORKSPACE"'); + expect(manualWindows).not.toContain("D:\\a\\Fusion\\Fusion"); expect(advisoryPackaging).toContain("Smoke embedded Postgres lifecycle"); expect(advisoryPackaging).toContain("pnpm --filter @fusion/core test:embedded-postgres"); }); + it("inspects Linux AppImage unpacked trees for embedded Postgres packaging", async () => { + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20: + * v0.60.0 AppImages existed but omitted embedded-postgres, main-bootstrap, and + * omp-runtime. Release + test-release must run the packaging content verifier + * after electron-builder produces linux-*-unpacked dirs. + */ + const release = await readRepoFile(".github/workflows/release.yml"); + const testRelease = await readRepoFile(".github/workflows/test-release.yml"); + const verifier = await readRepoFile("scripts/verify-desktop-linux-pg-packaging.mjs"); + + expect(verifier).toContain("main-bootstrap.cjs"); + expect(verifier).toContain("embedded-postgres"); + expect(verifier).toContain("omp-runtime"); + expect(verifier).toContain("@embedded-postgres"); + // FNXC:DesktopEmbeddedPostgres 2026-07-15-00:30: Greptile P1 locks — + // soname links (pg-symlinks) and runnable omp dist entrypoints, not just + // binary names / package-name substrings. + expect(verifier).toContain("pg-symlinks.json"); + expect(verifier).toContain("assertPlatformSonameLinks"); + expect(verifier).toContain("omp-runtime/dist/index.js"); + expect(verifier).toContain("omp-runtime/dist/probe.js"); + // FNXC:DesktopEmbeddedPostgres 2026-07-15-13:20: The x64 unpacked tree can + // carry optional arm64 packages too; retain validation of x64 binaries and + // SONAME links without treating that multi-arch closure as a packaging error. + expect(verifier).toContain("platforms.includes(expectedPlatform)"); + expect(verifier).toContain("expectedPlatform, \"native\", \"bin\""); + expect(verifier).not.toContain("found ${platform}; expected ${expectedPlatform}"); + + const advisoryPackaging = await readRepoFile(".github/workflows/desktop-packaging.yml"); + for (const workflow of [release, testRelease, advisoryPackaging]) { + expect(workflow).toContain("Verify Linux AppImage embedded Postgres packaging"); + expect(workflow).toContain("node scripts/verify-desktop-linux-pg-packaging.mjs"); + } + }); + it("wires release aggregation to include desktop assets across platforms", async () => { const release = await readRepoFile(".github/workflows/release.yml"); diff --git a/scripts/verify-desktop-linux-pg-packaging.mjs b/scripts/verify-desktop-linux-pg-packaging.mjs new file mode 100644 index 0000000000..d56fac124f --- /dev/null +++ b/scripts/verify-desktop-linux-pg-packaging.mjs @@ -0,0 +1,393 @@ +#!/usr/bin/env node +/* + * FNXC:DesktopEmbeddedPostgres 2026-07-15-00:20: + * Linux AppImage release verification for embedded Postgres packaging. + * v0.60.0 shipped without embedded-postgres / main-bootstrap / omp-runtime inside the + * AppImage, so Local mode could never boot on Linux. Existence of the .AppImage file + * alone is insufficient — assert the packaged Electron tree after electron-builder. + * + * Prefer inspecting electron-builder's linux-*-unpacked directories (no squashfs tools + * required). Optionally also list AppImage contents when unsquashfs is available. + * + * Usage: + * node scripts/verify-desktop-linux-pg-packaging.mjs + * node scripts/verify-desktop-linux-pg-packaging.mjs --dist packages/desktop/dist-electron + */ +import { spawnSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +// FNXC:DesktopEmbeddedPostgres 2026-07-15-10:45: +// Use fileURLToPath(import.meta.url) + path.dirname — eslint no-undef rejects +// bare `URL` in .mjs (not in env globals for this script). +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const require = createRequire(import.meta.url); + +/* + * FNXC:DesktopEmbeddedPostgres 2026-07-15-00:30: + * Greptile P1: omp-runtime can appear as package metadata without a built dist. + * Dashboard Local mode imports the package's import condition, which resolves to + * dist/index.js (and probe.js for runtime probes). Require those entrypoints. + */ +const OMP_RUNTIME_ASAR_ENTRYPOINTS = [ + "/node_modules/@fusion-plugin-examples/omp-runtime/dist/index.js", + "/node_modules/@fusion-plugin-examples/omp-runtime/dist/probe.js", +]; + +function parseArgs(argv) { + let distDir = join(repoRoot, "packages", "desktop", "dist-electron"); + for (let i = 0; i < argv.length; i += 1) { + if (argv[i] === "--dist" && argv[i + 1]) { + distDir = resolve(argv[++i]); + } + } + return { distDir }; +} + +function fail(message) { + console.error(`[verify-desktop-linux-pg] ${message}`); + process.exitCode = 1; +} + +function ok(message) { + console.log(`[verify-desktop-linux-pg] ${message}`); +} + +function findAsar() { + // @electron/asar is a transitive dep of electron-builder. Under pnpm it is + // often not importable via createRequire from the monorepo root, so fall back + // to scanning the virtual store after the normal resolve paths. + const resolvePaths = [ + join(repoRoot, "packages", "desktop"), + repoRoot, + process.cwd(), + ]; + for (const base of resolvePaths) { + try { + return require.resolve("@electron/asar/bin/asar.js", { paths: [base] }); + } catch { + // try next base + } + } + try { + return require.resolve("@electron/asar/bin/asar.js"); + } catch { + // continue to pnpm store scan + } + + for (const storeRoot of [ + join(repoRoot, "node_modules", ".pnpm"), + join(repoRoot, "packages", "desktop", "node_modules", ".pnpm"), + ]) { + if (!existsSync(storeRoot)) continue; + for (const entry of readdirSync(storeRoot)) { + if (!entry.startsWith("@electron+asar@")) continue; + const candidate = join( + storeRoot, + entry, + "node_modules", + "@electron", + "asar", + "bin", + "asar.js", + ); + if (existsSync(candidate)) return candidate; + } + } + return null; +} + +/** + * electron-builder writes one unpacked directory per arch under dist-electron. + * Accept both historical and current naming patterns. + */ +function discoverUnpackedDirs(distDir) { + if (!existsSync(distDir)) return []; + return readdirSync(distDir) + .filter((name) => { + if (!name.includes("unpacked")) return false; + const full = join(distDir, name); + try { + return statSync(full).isDirectory(); + } catch { + return false; + } + }) + .map((name) => join(distDir, name)) + .filter((full) => { + // Linux targets only (skip mac-unpacked / win-unpacked when mixed). + const base = full.split(/[/\\]/).pop() ?? ""; + return base.startsWith("linux") || base.includes("linux"); + }); +} + +function resourcesRoot(unpackedDir) { + // electron-builder linux layout: /resources/app.asar + const direct = join(unpackedDir, "resources"); + if (existsSync(join(direct, "app.asar"))) return direct; + return null; +} + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-15-00:30: + * Greptile P1: postgres/initdb can be present while postinstall soname links from + * hydrate-symlinks.js (pg-symlinks.json) are missing. Linux then fails at runtime + * with missing libicui18n.so.60 (etc.). Assert every recorded target path exists + * (symlink or regular file) under the platform native root. + */ +function assertPlatformSonameLinks(unpackedDir, platformRoot, platform) { + const nativeRoot = join(platformRoot, platform, "native"); + const markerPath = join(nativeRoot, "pg-symlinks.json"); + if (!existsSync(markerPath)) { + fail(`${unpackedDir}: ${platform} missing native/pg-symlinks.json (cannot verify soname links)`); + return; + } + + let entries; + try { + entries = JSON.parse(readFileSync(markerPath, "utf8")); + } catch (err) { + fail( + `${unpackedDir}: ${platform} native/pg-symlinks.json is not valid JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return; + } + if (!Array.isArray(entries) || entries.length === 0) { + fail(`${unpackedDir}: ${platform} native/pg-symlinks.json has no link entries`); + return; + } + + const packageRoot = join(platformRoot, platform); + let missing = 0; + for (const entry of entries) { + // FNXC:DesktopEmbeddedPostgres 2026-07-15-11:45: + // Review feedback requires malformed link manifests to fail closed. Skipping + // them could let an empty or producer-corrupted manifest claim SONAME success. + if (!entry || typeof entry !== "object") { + fail(`${unpackedDir}: ${platform} has an invalid pg-symlinks entry`); + missing += 1; + continue; + } + const sourceRel = typeof entry.source === "string" ? entry.source : ""; + const targetRel = typeof entry.target === "string" ? entry.target : ""; + if (!sourceRel || !targetRel) { + fail(`${unpackedDir}: ${platform} has a pg-symlinks entry without source/target`); + missing += 1; + continue; + } + + // Paths in pg-symlinks.json are relative to the platform package root + // (e.g. "native/lib/libicui18n.so.60.2" -> "native/lib/libicui18n.so.60"). + const sourcePath = join(packageRoot, sourceRel); + const targetPath = join(packageRoot, targetRel); + + if (!pathExists(sourcePath)) { + fail(`${unpackedDir}: ${platform} missing soname source ${sourceRel}`); + missing += 1; + continue; + } + if (!pathExists(targetPath)) { + fail( + `${unpackedDir}: ${platform} missing soname link target ${targetRel} ` + + `(hydrate-symlinks postinstall did not materialize ABI names; ` + + `embedded Postgres will fail with shared-library load errors)`, + ); + missing += 1; + } + } + if (missing === 0) { + ok(`${unpackedDir}: ${platform} soname links present (${entries.length} pg-symlinks entries)`); + } +} + +/** True if path exists as a real file, directory, or non-dangling symlink. */ +function pathExists(p) { + try { + const st = lstatSync(p); + if (st.isSymbolicLink()) { + // Dangling links must fail — existsSync follows and returns false for those. + return existsSync(p); + } + return true; + } catch { + return false; + } +} + +function listIncludesAsarPath(list, entry) { + // FNXC:DesktopEmbeddedPostgres 2026-07-15-11:45: + // ASAR listings may omit a leading slash. Normalize line entries before exact + // matching so a source map (for example index.js.map) cannot satisfy a runtime + // entrypoint requirement through a substring match. + const normalizedEntries = new Set( + list + .split(/\r?\n/) + .map((path) => `/${path.trim().replace(/^\/+/, "")}`) + .filter((path) => path !== "/"), + ); + return normalizedEntries.has(`/${entry.replace(/^\/+/, "")}`); +} + +function expectedLinuxPlatform(unpackedDir) { + const name = unpackedDir.split(/[/\\]/).pop() ?? ""; + // electron-builder calls x64's default output linux-unpacked and appends + // non-default target architectures such as linux-arm64-unpacked. + return name.includes("arm64") ? "linux-arm64" : "linux-x64"; +} + +function isRegularExecutable(path) { + try { + const stat = statSync(path); + return stat.isFile() && (stat.mode & 0o111) !== 0; + } catch { + return false; + } +} + +function assertUnpackedTree(unpackedDir, asarBin) { + const resources = resourcesRoot(unpackedDir); + if (!resources) { + fail(`${unpackedDir}: missing resources/app.asar`); + return; + } + + const asarPath = join(resources, "app.asar"); + const unpackedNm = join(resources, "app.asar.unpacked", "node_modules"); + const embeddedRoot = join(unpackedNm, "embedded-postgres"); + const platformRoot = join(unpackedNm, "@embedded-postgres"); + + if (!existsSync(embeddedRoot)) { + fail(`${unpackedDir}: app.asar.unpacked is missing embedded-postgres (asarUnpack/files allowlist broken)`); + } else { + ok(`${unpackedDir}: embedded-postgres present under app.asar.unpacked`); + } + + if (!existsSync(platformRoot)) { + fail(`${unpackedDir}: app.asar.unpacked is missing @embedded-postgres/*`); + } else { + const platforms = readdirSync(platformRoot).filter((n) => n.startsWith("linux-")); + const expectedPlatform = expectedLinuxPlatform(unpackedDir); + if (platforms.length === 0) { + fail(`${unpackedDir}: no @embedded-postgres/linux-* packages in asar.unpacked (got: ${readdirSync(platformRoot).join(", ") || "none"})`); + } else { + ok(`${unpackedDir}: platform packages: ${platforms.join(", ")}`); + // FNXC:DesktopEmbeddedPostgres 2026-07-15-13:20: + // electron-builder can retain optional native packages for several Linux + // CPUs in one unpacked tree. Validate the target CPU's runnable payload, + // rather than rejecting extra architecture packages that do not affect it. + if (!platforms.includes(expectedPlatform)) { + fail(`${unpackedDir}: missing @embedded-postgres/${expectedPlatform}`); + } else { + for (const bin of ["initdb", "pg_ctl", "postgres"]) { + const binPath = join(platformRoot, expectedPlatform, "native", "bin", bin); + if (!isRegularExecutable(binPath)) { + fail(`${unpackedDir}: missing executable file ${expectedPlatform}/native/bin/${bin}`); + } + } + assertPlatformSonameLinks(unpackedDir, platformRoot, expectedPlatform); + } + } + } + + // package.json main must be the CJS bootstrap that patches spawn before ESM main. + if (!asarBin) { + fail("Could not resolve @electron/asar; cannot read packaged package.json main"); + return; + } + const listed = spawnSync(process.execPath, [asarBin, "list", asarPath], { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + if (listed.status !== 0) { + fail(`asar list failed for ${asarPath}: ${listed.stderr || listed.stdout}`); + return; + } + const list = listed.stdout; + if (!listIncludesAsarPath(list, "/dist/main-bootstrap.cjs")) { + fail(`${unpackedDir}: app.asar is missing dist/main-bootstrap.cjs`); + } else { + ok(`${unpackedDir}: main-bootstrap.cjs present in app.asar`); + } + + // Require the runnable dist entrypoints, not a bare "omp-runtime" substring. + const missingOmp = OMP_RUNTIME_ASAR_ENTRYPOINTS.filter((entry) => !listIncludesAsarPath(list, entry)); + if (missingOmp.length > 0) { + fail( + `${unpackedDir}: app.asar missing omp-runtime dist entrypoint(s): ${missingOmp.join(", ")} ` + + `(package metadata alone is insufficient; Local mode fails with ERR_MODULE_NOT_FOUND after PG boot)`, + ); + } else { + ok(`${unpackedDir}: omp-runtime dist entrypoints present in app.asar`); + } + + // extract-file writes package.json into cwd — use a private temp dir under resources. + const extractCwd = join(resources, ".fusion-pg-verify-tmp"); + try { + rmSync(extractCwd, { recursive: true, force: true }); + mkdirSync(extractCwd, { recursive: true }); + const extracted = spawnSync( + process.execPath, + [asarBin, "extract-file", asarPath, "package.json"], + { encoding: "utf8", cwd: extractCwd }, + ); + const pkgPath = join(extractCwd, "package.json"); + if (extracted.status !== 0 || !existsSync(pkgPath)) { + fail( + `${unpackedDir}: failed to extract package.json from app.asar (${extracted.stderr || extracted.stdout})`, + ); + return; + } + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); + if (pkg.main !== "dist/main-bootstrap.cjs") { + fail( + `${unpackedDir}: package.json main is ${JSON.stringify(pkg.main)}; expected "dist/main-bootstrap.cjs"`, + ); + } else { + ok(`${unpackedDir}: package.json main is dist/main-bootstrap.cjs`); + } + } catch (err) { + fail(`${unpackedDir}: package.json main check error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + rmSync(extractCwd, { recursive: true, force: true }); + } +} + +function main() { + const { distDir } = parseArgs(process.argv.slice(2)); + ok(`inspecting ${distDir}`); + + const unpacked = discoverUnpackedDirs(distDir); + if (unpacked.length === 0) { + fail( + `No linux-*-unpacked directories under ${distDir}. ` + + "Run electron-builder --linux first (or pass --dist to the packaging output).", + ); + return; + } + + const asarBin = findAsar(); + for (const dir of unpacked) { + assertUnpackedTree(dir, asarBin); + } + + if (process.exitCode && process.exitCode !== 0) { + fail("Linux desktop embedded Postgres packaging verification FAILED"); + return; + } + ok("Linux desktop embedded Postgres packaging verification passed"); +} + +main();