From 75687052442dc669573bc78b615467ffbf907a7d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 20:44:09 -0700 Subject: [PATCH] fix(desktop): boot embedded Postgres in packaged app and ship omp dist (#2106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Packaged Fusion desktop Local mode failed after the SQLite→Postgres cutover: 1. **Embedded Postgres** could not start from `app.asar` — platform packages resolve `initdb`/`postgres` via `import.meta.url` into the asar virtual path, and `spawn` fails with `ENOTDIR`. 2. **After Postgres was fixed**, Local mode still fell back to the mode chooser because `@fusion-plugin-examples/omp-runtime` was never built into `dist/` (dashboard imports it from `runtime-provider-probes.ts`). This PR makes packaged Local mode boot embedded Postgres reliably and keep the dashboard shell up. ### Changes - **CJS bootstrap** (`main-bootstrap.cjs`) as Electron `main`: patches `child_process.spawn` / `fs.promises.stat|chmod` before the ESM main loads so asar binary paths rewrite to real files. - **Materialize** the full native PG install (`bin` + `lib` + `share`) under `~/.fusion/embedded-postgres/runtime-bin//`. - **electron-builder**: full `asarUnpack` of embedded-postgres packages; allowlist PG deps and `@fusion-plugin-examples/**/*` (+ plugin-sdk / ACP SDK). - **Build** `fusion-plugin-omp-runtime` with the other dashboard-static runtime plugins; export `DASHBOARD_RUNTIME_PLUGIN_PACKAGES` for tests. - Unit coverage for asar path rewrite, packaging allowlists, and omp build inclusion. ## Test plan - [x] `pnpm --filter @fusion/core test:embedded-postgres` (23/23) - [x] Desktop packaging unit tests (`build-bundling`, `electron-builder-config`) - [x] Packaged macOS `Fusion.app` Local mode: - [x] `embedded postgres: ready on port … (database "fusion")` - [x] `desktopMode` stays `"local"` (no chooser fallback) - [x] `GET /api/health` → `status: ok`, `database.healthy: true`, `engine.available: true` - [x] Linux embedded binary lifecycle smoke (Docker aarch64, `@embedded-postgres/linux-arm64`) — initdb/start/persist/restart - [ ] CI release desktop jobs (macOS/Linux) when this lands - [ ] Windows packaged desktop Local + PG (separate agent / host) ## Verification notes | Platform | Embedded Postgres | Packaged Local shell | |----------|-------------------|----------------------| | macOS | Working | Working after this PR | | Linux | Native binary smoke pass | Full AppImage not built on this host | | Windows | Out of scope here | Separate verification | ## Summary by CodeRabbit * **Bug Fixes** * Improved embedded PostgreSQL reliability in Electron-packaged apps by rewriting bundled `app.asar` binary paths to their unpacked/materialized locations. * Ensured embedded PostgreSQL runtime binaries resolve correctly across platforms/architectures, with best-effort executable permissions and macOS dylib link normalization. * **Packaging** * Updated the desktop Electron entry to use a bootstrap module for embedded PostgreSQL binary resolution. * Expanded Electron Builder inclusion and asar-unpack rules for embedded-postgres and related packages, plus required runtime plugin/sdk assets. * **Tests** * Updated and added checks to match the new packaging and plugin/runtime expectations. --- .../postgres/embedded-lifecycle.test.ts | 311 ++++++++++++- .../core/src/postgres/embedded-lifecycle.ts | 408 +++++++++++++++++- packages/desktop/electron-builder.yml | 53 ++- packages/desktop/package.json | 2 +- packages/desktop/scripts/build.ts | 14 +- packages/desktop/scripts/workspace-tools.ts | 31 +- .../src/__tests__/build-bundling.test.ts | 18 + .../__tests__/electron-builder-config.test.ts | 38 +- packages/desktop/src/main-bootstrap.cjs | 85 ++++ 9 files changed, 927 insertions(+), 33 deletions(-) create mode 100644 packages/desktop/src/main-bootstrap.cjs diff --git a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts index cb51e0ce4c..d31edb0a03 100644 --- a/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts +++ b/packages/core/src/__tests__/postgres/embedded-lifecycle.test.ts @@ -20,12 +20,14 @@ import { existsSync, rmSync, writeFileSync, + readFileSync, readlinkSync, mkdirSync, symlinkSync, } from "node:fs"; +import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import postgres from "postgres"; import { EmbeddedPostgresLifecycle, @@ -34,9 +36,17 @@ import { isDataDirInitialized, normalizeMacosEmbeddedPostgresDylibSymlinks, readPortFromPostmasterPid, + resolveElectronAsarUnpackedPath, + fingerprintEmbeddedPostgresNativeRoot, + buildEmbeddedPostgresMaterializationMarker, + materializeEmbeddedPostgresRuntimeBinaries, + installElectronAsarNativePathPatch, + uninstallElectronAsarNativePathPatchForTests, type EmbeddedLifecycleOptions, } from "../../postgres/embedded-lifecycle.js"; +const testRequire = createRequire(import.meta.url); + const SKIP = process.env.FUSION_EMBEDDED_TEST_SKIP === "1"; const embeddedDescribe = SKIP ? describe.skip : describe; @@ -147,6 +157,305 @@ describe("embedded-lifecycle: constructor + URL helpers (no process)", () => { }); }); +describe("embedded-lifecycle: Electron asar unpacked path rewrite", () => { + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:30: + * Packaged desktop resolves platform binaries under app.asar even when + * asarUnpack places them on disk under app.asar.unpacked. Prove the rewrite + * prefers the real unpacked file and leaves ordinary paths alone. + * + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Use a non-materialized binary name (`pg_dump`) so these assertions never + * short-circuit through the user's real ~/.fusion runtime-bin cache (which + * only applies to postgres/initdb/pg_ctl basenames). + */ + it("rewrites app.asar binary paths to app.asar.unpacked when present", () => { + const root = mkdtempSync(join(tmpdir(), "fusion-asar-rewrite-")); + try { + // pg_dump is intentionally NOT in the materialization BIN_NAMES set. + const asarBin = join(root, "app.asar", "node_modules", "pkg", "bin", "pg_dump"); + const unpackedBin = join(root, "app.asar.unpacked", "node_modules", "pkg", "bin", "pg_dump"); + mkdirSync(dirname(unpackedBin), { recursive: true }); + writeFileSync(unpackedBin, ""); + expect(resolveElectronAsarUnpackedPath(asarBin)).toBe(unpackedBin); + expect(resolveElectronAsarUnpackedPath(unpackedBin)).toBe(unpackedBin); + expect(resolveElectronAsarUnpackedPath(join(root, "plain", "pg_dump"))).toBe( + join(root, "plain", "pg_dump"), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps the asar path when no unpacked twin exists", () => { + const asarOnly = join(tmpdir(), "app.asar", "missing", "pg_dump"); + expect(resolveElectronAsarUnpackedPath(asarOnly)).toBe(asarOnly); + }); + + it("patched spawn/stat/chmod receive rewritten asar paths without real processes", async () => { + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Surface enumeration: exercise the production patch entry points (CJS + * child_process.spawn + fs.promises.stat/chmod), not only the pure path + * resolver. Install a recording bottom layer, then the production patch on + * top, and assert rewritten paths without launching Postgres. + */ + const root = mkdtempSync(join(tmpdir(), "fusion-asar-patch-")); + const childProcessMod = testRequire("child_process") as { + spawn: (...args: unknown[]) => unknown; + }; + const fsPromisesMod = testRequire("fs/promises") as { + stat: (...args: unknown[]) => unknown; + chmod: (...args: unknown[]) => unknown; + }; + // Undo any prior production patch so we can install fakes as the bottom layer. + uninstallElectronAsarNativePathPatchForTests(); + const prevSpawn = childProcessMod.spawn; + const prevStat = fsPromisesMod.stat; + const prevChmod = fsPromisesMod.chmod; + const spawnSeen: unknown[] = []; + const statSeen: unknown[] = []; + const chmodSeen: unknown[] = []; + try { + const asarBin = join(root, "app.asar", "node_modules", "pkg", "bin", "pg_dump"); + const unpackedBin = join( + root, + "app.asar.unpacked", + "node_modules", + "pkg", + "bin", + "pg_dump", + ); + mkdirSync(dirname(unpackedBin), { recursive: true }); + writeFileSync(unpackedBin, "fake-bin"); + const plainPath = join(root, "plain", "pg_dump"); + + // Bottom-layer fakes record the command/path the production patch forwards. + childProcessMod.spawn = (command: unknown, ..._rest: unknown[]) => { + spawnSeen.push(command); + return { + pid: 0, + on: () => undefined, + kill: () => true, + stdout: null, + stderr: null, + }; + }; + fsPromisesMod.stat = async (p: unknown, ..._rest: unknown[]) => { + statSeen.push(p); + return { mode: 0o755, isFile: () => true }; + }; + fsPromisesMod.chmod = async (p: unknown, ..._rest: unknown[]) => { + chmodSeen.push(p); + }; + + installElectronAsarNativePathPatch(); + + childProcessMod.spawn(asarBin); + childProcessMod.spawn(plainPath); + await fsPromisesMod.stat(asarBin); + await fsPromisesMod.stat(plainPath); + await fsPromisesMod.chmod(asarBin, 0o755); + await fsPromisesMod.chmod(plainPath, 0o755); + + expect(spawnSeen).toEqual([unpackedBin, plainPath]); + expect(statSeen).toEqual([unpackedBin, plainPath]); + expect(chmodSeen).toEqual([unpackedBin, plainPath]); + } finally { + // Restore production patch wrapper first (back to fakes), then real builtins. + uninstallElectronAsarNativePathPatchForTests(); + childProcessMod.spawn = prevSpawn; + fsPromisesMod.stat = prevStat; + fsPromisesMod.chmod = prevChmod; + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("embedded-lifecycle: materialize runtime binaries (update-safe marker)", () => { + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-15-02:55: + * Greptile P1 security: path-only `.materialized-from` markers reuse stale + * Postgres binaries after an in-place packaged app update (nativeRoot path is + * stable). Marker must include a content fingerprint so payload changes force + * a re-copy of the host-local runtime-bin cache. + * + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Fingerprint now content-hashes lib/ + share/ (not path+size only) so + * same-size library/support-file patches invalidate the cache. + */ + const postgresBin = process.platform === "win32" ? "postgres.exe" : "postgres"; + const initdbBin = process.platform === "win32" ? "initdb.exe" : "initdb"; + const pgCtlBin = process.platform === "win32" ? "pg_ctl.exe" : "pg_ctl"; + + function seedNativeRoot(root: string, postgresBody: string): void { + mkdirSync(join(root, "bin"), { recursive: true }); + mkdirSync(join(root, "lib", "postgresql"), { recursive: true }); + mkdirSync(join(root, "share", "postgresql"), { recursive: true }); + writeFileSync(join(root, "bin", postgresBin), postgresBody); + writeFileSync(join(root, "bin", initdbBin), "initdb-stub"); + writeFileSync(join(root, "bin", pgCtlBin), "pg_ctl-stub"); + writeFileSync(join(root, "lib", "postgresql", "plpgsql.so"), "ext-v1"); + writeFileSync(join(root, "share", "postgresql", "postgres.bki"), "share-v1"); + } + + it("fingerprint changes when binary contents change at the same path", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-fp-")); + try { + seedNativeRoot(nativeRoot, "postgres-v1"); + const first = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + writeFileSync(join(nativeRoot, "bin", postgresBin), "postgres-v2-updated-payload"); + const second = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + expect(first).not.toBe(second); + expect(first).toMatch(/^[a-f0-9]{64}$/); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + } + }); + + it("fingerprint changes when a library payload changes without bin renames", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-fp-lib-")); + try { + seedNativeRoot(nativeRoot, "postgres-stable"); + const first = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + // Same name and size, different contents — must invalidate (content hash). + writeFileSync(join(nativeRoot, "lib", "postgresql", "plpgsql.so"), "ext-v2"); + const second = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + expect(first).not.toBe(second); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + } + }); + + it("fingerprint changes when share support files change without bin renames", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-fp-share-")); + try { + seedNativeRoot(nativeRoot, "postgres-stable"); + const first = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + // share/ is copied into runtime-bin and must participate in the fingerprint. + writeFileSync(join(nativeRoot, "share", "postgresql", "postgres.bki"), "share-v2"); + const second = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + expect(first).not.toBe(second); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + } + }); + + it("same-size library content change forces rematerialization", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-samesize-")); + const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-samesize-dst-")); + try { + seedNativeRoot(nativeRoot, "postgres-stable"); + materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot }); + expect(readFileSync(join(destRoot, "lib", "postgresql", "plpgsql.so"), "utf8")).toBe( + "ext-v1", + ); + + // Equal-length security patch at the same path (in-place app update). + writeFileSync(join(nativeRoot, "lib", "postgresql", "plpgsql.so"), "ext-v2"); + materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot }); + expect(readFileSync(join(destRoot, "lib", "postgresql", "plpgsql.so"), "utf8")).toBe( + "ext-v2", + ); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + rmSync(destRoot, { recursive: true, force: true }); + } + }); + + it("buildEmbeddedPostgresMaterializationMarker includes path + fingerprint", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-marker-build-")); + try { + seedNativeRoot(nativeRoot, "postgres-body"); + const marker = buildEmbeddedPostgresMaterializationMarker(nativeRoot); + const fingerprint = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + expect(marker.startsWith("v2\n")).toBe(true); + expect(marker).toContain(nativeRoot); + expect(marker).toContain(fingerprint); + // Path alone must not equal the full marker (legacy path-only markers rematerialize). + expect(marker.trim()).not.toBe(nativeRoot); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + } + }); + + it("skips re-copy when marker + fingerprint still match (idempotent)", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-src-")); + const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-dst-")); + try { + seedNativeRoot(nativeRoot, "postgres-stable"); + // First materialization. + materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot }); + const markerPath = join(destRoot, ".materialized-from"); + const markerAfterFirst = readFileSync(markerPath, "utf8"); + const destPostgres = join(destRoot, "bin", postgresBin); + expect(readFileSync(destPostgres, "utf8")).toBe("postgres-stable"); + // Destination-only sentinel: an always-recopy implementation would wipe it. + const reuseSentinel = join(destRoot, ".reuse-sentinel"); + writeFileSync(reuseSentinel, "preserve"); + + // Second call must reuse (same path + same fingerprint) without error. + const returned = materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot }); + expect(returned).toBe(destRoot); + expect(readFileSync(reuseSentinel, "utf8")).toBe("preserve"); + expect(readFileSync(markerPath, "utf8")).toBe(markerAfterFirst); + expect(readFileSync(destPostgres, "utf8")).toBe("postgres-stable"); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + rmSync(destRoot, { recursive: true, force: true }); + } + }); + + it("re-copies when payload changes even though nativeRoot path is unchanged", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-update-")); + const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-update-dst-")); + try { + seedNativeRoot(nativeRoot, "postgres-release-1"); + materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot }); + expect(readFileSync(join(destRoot, "bin", postgresBin), "utf8")).toBe("postgres-release-1"); + // Leave a stale orphan that force-copy alone would not remove. + writeFileSync(join(destRoot, "bin", "orphan-from-old-release"), "stale"); + + // Simulate in-place app update: same nativeRoot path, new binary payload. + writeFileSync(join(nativeRoot, "bin", postgresBin), "postgres-release-2"); + materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot }); + + expect(readFileSync(join(destRoot, "bin", postgresBin), "utf8")).toBe("postgres-release-2"); + // Rematerialization clears dest first so orphans from prior releases do not linger. + expect(existsSync(join(destRoot, "bin", "orphan-from-old-release"))).toBe(false); + expect(readFileSync(join(destRoot, ".materialized-from"), "utf8")).toBe( + buildEmbeddedPostgresMaterializationMarker(nativeRoot), + ); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + rmSync(destRoot, { recursive: true, force: true }); + } + }); + + it("treats legacy path-only markers as stale and rematerializes", () => { + const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-legacy-")); + const destRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-mat-legacy-dst-")); + try { + seedNativeRoot(nativeRoot, "postgres-current"); + // Seed dest as if an older build wrote path-only markers. + mkdirSync(join(destRoot, "bin"), { recursive: true }); + mkdirSync(join(destRoot, "lib", "postgresql"), { recursive: true }); + writeFileSync(join(destRoot, "bin", postgresBin), "postgres-stale-legacy"); + writeFileSync(join(destRoot, "lib", "postgresql", "plpgsql.so"), "old"); + writeFileSync(join(destRoot, ".materialized-from"), nativeRoot); + + materializeEmbeddedPostgresRuntimeBinaries(nativeRoot, { destRoot }); + expect(readFileSync(join(destRoot, "bin", postgresBin), "utf8")).toBe("postgres-current"); + expect(readFileSync(join(destRoot, ".materialized-from"), "utf8")).toBe( + buildEmbeddedPostgresMaterializationMarker(nativeRoot), + ); + } finally { + rmSync(nativeRoot, { recursive: true, force: true }); + rmSync(destRoot, { recursive: true, force: true }); + } + }); +}); + describe("embedded-lifecycle: macOS dylib compatibility links", () => { it("repairs missing compatibility-name symlinks from versioned dylibs", () => { const nativeRoot = mkdtempSync(join(tmpdir(), "fusion-embedded-native-")); diff --git a/packages/core/src/postgres/embedded-lifecycle.ts b/packages/core/src/postgres/embedded-lifecycle.ts index 5fb55aecd2..ab4b364db7 100644 --- a/packages/core/src/postgres/embedded-lifecycle.ts +++ b/packages/core/src/postgres/embedded-lifecycle.ts @@ -45,22 +45,384 @@ // the flip-embedded-pg-default change; the runtime startup factory is the // sole caller and it dynamically imports this module only in that case). import { + cpSync, existsSync, lstatSync, + mkdirSync, readFileSync, + readlinkSync, readdirSync, + rmSync, + statSync, symlinkSync, unlinkSync, + chmodSync, + writeFileSync, } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; import { createServer, type Server } from "node:net"; -import { dirname, join, basename } from "node:path"; -import { createRequire } from "node:module"; +import { dirname, join, basename, sep } from "node:path"; +import { createRequire, syncBuiltinESMExports } from "node:module"; import { createLogger } from "../logger.js"; import { redactConnectionString } from "./credential-redact.js"; import type { ResolvedBackend } from "./backend-resolver.js"; const require = createRequire(import.meta.url); +const EMBEDDED_PG_BIN_NAMES = new Set([ + "postgres", + "initdb", + "pg_ctl", + "postgres.exe", + "initdb.exe", + "pg_ctl.exe", +]); + +/* + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Bump when the marker payload shape or fingerprint algorithm changes so older + * host-local caches always rematerialize after a desktop update that ships a + * new fingerprinting strategy (e.g. content-hashing lib/share, not path+size). + */ +const MATERIALIZATION_MARKER_VERSION = 2; + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:30: + * Electron packages app code into app.asar. Platform package entrypoints resolve + * native binary paths via import.meta.url, so paths look like + * `.../app.asar/node_modules/@embedded-postgres/.../native/bin/postgres` even when + * asarUnpack places the real files under `app.asar.unpacked/...`. + * Node's spawn/chmod against the asar virtual path fail with ENOTDIR; rewrite to + * the unpacked real path when that file exists. No-op outside Electron asar trees. + */ +export function resolveElectronAsarUnpackedPath(filePath: string): string { + if (!filePath) return filePath; + // Prefer a materialized runtime-bin path when we already copied binaries out of asar. + const materialized = resolveMaterializedEmbeddedPostgresBinary(filePath); + if (materialized) return materialized; + if (filePath.includes(`${sep}app.asar.unpacked${sep}`)) { + return filePath; + } + const marker = `${sep}app.asar${sep}`; + const index = filePath.indexOf(marker); + if (index === -1) return filePath; + const unpacked = + filePath.slice(0, index) + + `${sep}app.asar.unpacked${sep}` + + filePath.slice(index + marker.length); + return existsSync(unpacked) ? unpacked : filePath; +} + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:45: + * Host-local copy of packaged embedded Postgres binaries. Electron can still treat + * paths that contain the `app.asar` substring oddly at spawn time on some hosts; + * materializing into ~/.fusion avoids asar virtual-path issues entirely. + */ +export function embeddedPostgresRuntimeBinRoot( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + home: string = homedir(), +): string { + return join(home, ".fusion", "embedded-postgres", "runtime-bin", `${platform}-${arch}`); +} + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-15-02:55: + * Content-aware fingerprint of a packaged native root. Packaged in-place updates + * keep `nativeRoot` path stable (same app.asar.unpacked layout) while replacing + * postgres binaries and libraries; path-only markers would reuse stale payload. + * Fingerprint mixes binary content hashes + sizes so both binary and library-only + * payload changes invalidate the host-local materialization cache. + * + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Greptile P1: path+size for lib files missed same-size content patches, and + * share/ was copied into runtime-bin but omitted from the fingerprint. Hash full + * file contents under bin/ + lib/ + share/ so any payload byte change forces + * rematerialization after an in-place app update. + */ +export function fingerprintEmbeddedPostgresNativeRoot(nativeRoot: string): string { + const hash = createHash("sha256"); + const binNames = + process.platform === "win32" + ? (["postgres.exe", "initdb.exe", "pg_ctl.exe"] as const) + : (["postgres", "initdb", "pg_ctl"] as const); + for (const name of binNames) { + const path = join(nativeRoot, "bin", name); + hash.update(name); + hash.update("\0"); + try { + const st = statSync(path); + hash.update(String(st.size)); + hash.update("\0"); + // Full content hash of each critical binary (sizes are small enough for startup). + hash.update(readFileSync(path)); + } catch { + hash.update("missing"); + } + hash.update("\0"); + } + // Full content walk of every tree that materialize() copies (lib + share). + // Budget bounds pathological trees; real embedded-postgres installs are well under it. + const budget = { remaining: 4096 }; + for (const tree of ["lib", "share"] as const) { + const treeDir = join(nativeRoot, tree); + hash.update(`tree:${tree}\0`); + if (existsSync(treeDir)) { + try { + hashPayloadTreeContents(treeDir, tree, hash, budget); + } catch { + hash.update(`${tree}-unreadable`); + } + } else { + hash.update(`${tree}-missing`); + } + } + return hash.digest("hex"); +} + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Recursive content fingerprint for a payload subtree (lib/ or share/). Records + * relative path + full file bytes so same-size security patches invalidate the + * materialization marker. Directory entries are structural markers only. + */ +function hashPayloadTreeContents( + absDir: string, + relPrefix: string, + hash: ReturnType, + budget: { remaining: number }, +): void { + if (budget.remaining <= 0) return; + let entries: string[]; + try { + entries = readdirSync(absDir).sort(); + } catch { + return; + } + for (const entry of entries) { + if (budget.remaining <= 0) return; + const abs = join(absDir, entry); + const rel = relPrefix ? `${relPrefix}/${entry}` : entry; + try { + // lstat so macOS dylib compatibility symlinks are fingerprinted by target + // name (not followed) and same-size file content patches still hash bytes. + const st = lstatSync(abs); + if (st.isSymbolicLink()) { + budget.remaining -= 1; + hash.update(`l:${rel}:`); + try { + hash.update(readlinkSync(abs)); + } catch { + hash.update("?"); + } + hash.update("\0"); + } else if (st.isDirectory()) { + hash.update(`d:${rel}\0`); + hashPayloadTreeContents(abs, rel, hash, budget); + } else if (st.isFile()) { + budget.remaining -= 1; + hash.update(`f:${rel}\0`); + try { + hash.update(readFileSync(abs)); + } catch { + hash.update("unreadable"); + } + hash.update("\0"); + } + } catch { + hash.update(`?:${rel}\0`); + } + } +} + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-15-02:55: + * Materialization cache marker. Must change whenever either the source path OR + * the native payload changes so in-place app updates re-copy fresh Postgres + * binaries instead of reusing the previous release's host-local cache. + * Legacy path-only markers fail equality and force rematerialization. + */ +export function buildEmbeddedPostgresMaterializationMarker(nativeRoot: string): string { + const fingerprint = fingerprintEmbeddedPostgresNativeRoot(nativeRoot); + return `v${MATERIALIZATION_MARKER_VERSION}\n${nativeRoot}\n${fingerprint}\n`; +} + +function resolveMaterializedEmbeddedPostgresBinary(filePath: string): string | null { + const name = basename(filePath); + if (!EMBEDDED_PG_BIN_NAMES.has(name)) return null; + if (!filePath.includes(`${sep}app.asar`)) return null; + const candidate = join(embeddedPostgresRuntimeBinRoot(), "bin", name); + return existsSync(candidate) ? candidate : null; +} + +export interface MaterializeEmbeddedPostgresOptions { + /** + * Override the host-local dest root (tests). Defaults to + * `~/.fusion/embedded-postgres/runtime-bin/-`. + */ + readonly destRoot?: string; +} + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:45: + * Copy initdb/pg_ctl/postgres (+ lib tree for dyld/@loader_path) from the packaged + * native root into ~/.fusion so spawn never has to execute out of app.asar*. + * Idempotent: skips when marker + binaries already exist. + * + * FNXC:DesktopEmbeddedPostgres 2026-07-15-02:55: + * Marker identity includes a content fingerprint of the source native root, not + * only the path. Packaged app updates leave nativeRoot paths unchanged; without + * the fingerprint this guard would keep serving the previous release's binaries + * and libraries and bundled PostgreSQL fixes would never take effect. + */ +export function materializeEmbeddedPostgresRuntimeBinaries( + nativeRoot: string, + options?: MaterializeEmbeddedPostgresOptions, +): string { + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:55: + * Postgres expects a full install layout next to the binaries: bin/, lib/ + * (including lib/postgresql extension modules), and share/postgresql. + * A shallow bin-only copy fails at start with "could not open directory + * .../lib/postgresql". Copy the entire native root recursively. + */ + const destRoot = options?.destRoot ?? embeddedPostgresRuntimeBinRoot(); + const destBin = join(destRoot, "bin"); + const marker = join(destRoot, ".materialized-from"); + const sourceMarker = buildEmbeddedPostgresMaterializationMarker(nativeRoot); + if ( + existsSync(marker) && + readFileSync(marker, "utf8") === sourceMarker && + existsSync(join(destBin, process.platform === "win32" ? "postgres.exe" : "postgres")) && + existsSync(join(destRoot, "lib", "postgresql")) + ) { + return destRoot; + } + + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-15-02:55: + * Clear the previous materialization before re-copy so files removed in a newer + * payload cannot linger beside the updated binaries (force-copy alone does not + * delete orphans). + */ + if (existsSync(destRoot)) { + rmSync(destRoot, { recursive: true, force: true }); + } + mkdirSync(destRoot, { recursive: true }); + // Recursive copy of bin/lib/share (and any other native install dirs). + for (const entry of readdirSync(nativeRoot)) { + const from = join(nativeRoot, entry); + const to = join(destRoot, entry); + cpSync(from, to, { recursive: true, force: true }); + } + // Ensure executables keep +x after asar materialization. + if (existsSync(destBin)) { + for (const name of readdirSync(destBin)) { + try { + chmodSync(join(destBin, name), 0o755); + } catch { + // best-effort + } + } + } + // Re-apply macOS ABI compatibility links against the materialized lib dir. + normalizeMacosEmbeddedPostgresDylibSymlinks(destRoot); + writeFileSync(marker, sourceMarker, "utf8"); + return destRoot; +} + +let electronAsarNativePathPatchInstalled = false; +/** Restores the pre-patch CJS/ESM builtins; used by unit tests only. */ +let electronAsarNativePathPatchRestore: (() => void) | null = null; + +type MutableSpawnModule = { + spawn: (...args: unknown[]) => unknown; +}; +type MutableFsPromisesModule = { + stat: (...args: unknown[]) => unknown; + chmod: (...args: unknown[]) => unknown; +}; + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:30: + * Install once before constructing embedded-postgres. That library calls + * fs.promises.stat/chmod and child_process.spawn on binary paths derived from + * asar module URLs; without this patch, packaged desktop local mode cannot boot + * Postgres (ENOTDIR). + * + * Mutate the CJS exports objects (`require("child_process")` / `require("fs/promises")`) + * rather than the frozen ESM namespace (`import * as ...`). + * + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Replacing CJS export properties does NOT automatically update already-resolved + * ESM named imports of the same builtins — they keep the pre-patch function until + * `syncBuiltinESMExports()` runs. Call it after every CJS mutation so ESM importers + * of child_process/fs.promises (including embedded-postgres) observe the rewrite. + * Safe outside Electron — rewrite is a no-op without asar. + */ +export function installElectronAsarNativePathPatch(): void { + if (electronAsarNativePathPatchInstalled) return; + electronAsarNativePathPatchInstalled = true; + + // Materialize only when the platform package lives under Electron's asar tree. + // Dev/CLI installs already use real filesystem paths and must not copy binaries. + try { + const nativeRoot = resolveGenericEmbeddedPostgresNativeRoot(); + if (nativeRoot && nativeRoot.includes(`${sep}app.asar`)) { + const sourceRoot = resolveElectronAsarUnpackedPath(nativeRoot); + if (existsSync(join(sourceRoot, "bin"))) { + materializeEmbeddedPostgresRuntimeBinaries(sourceRoot); + } + } + } catch { + // Materialization is best-effort; path rewrite still helps when possible. + } + + const childProcessMod = require("child_process") as MutableSpawnModule; + const originalSpawn = childProcessMod.spawn.bind(childProcessMod); + childProcessMod.spawn = (command: unknown, ...rest: unknown[]) => { + const fixedCommand = + typeof command === "string" ? resolveElectronAsarUnpackedPath(command) : command; + return originalSpawn(fixedCommand, ...rest); + }; + + const fsPromisesMod = require("fs/promises") as MutableFsPromisesModule; + const originalStat = fsPromisesMod.stat.bind(fsPromisesMod); + fsPromisesMod.stat = (path: unknown, ...rest: unknown[]) => { + const fixedPath = typeof path === "string" ? resolveElectronAsarUnpackedPath(path) : path; + return originalStat(fixedPath, ...rest); + }; + const originalChmod = fsPromisesMod.chmod.bind(fsPromisesMod); + fsPromisesMod.chmod = (path: unknown, ...rest: unknown[]) => { + const fixedPath = typeof path === "string" ? resolveElectronAsarUnpackedPath(path) : path; + return originalChmod(fixedPath, ...rest); + }; + + // Propagate CJS mutations to ESM named exports (spawn/stat/chmod). + syncBuiltinESMExports(); + + electronAsarNativePathPatchRestore = () => { + childProcessMod.spawn = originalSpawn; + fsPromisesMod.stat = originalStat; + fsPromisesMod.chmod = originalChmod; + syncBuiltinESMExports(); + electronAsarNativePathPatchInstalled = false; + electronAsarNativePathPatchRestore = null; + }; +} + +/** + * FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: + * Test-only undo for {@link installElectronAsarNativePathPatch} so unit tests can + * install a recording bottom-layer spawn/stat/chmod stub, then reinstall the + * production patch on top and assert rewritten paths without real processes. + */ +export function uninstallElectronAsarNativePathPatchForTests(): void { + electronAsarNativePathPatchRestore?.(); +} + /** * Lazily resolve the `embedded-postgres` default export. Cached after the * first call. Throws if the package is not installed (e.g. a stripped-down @@ -82,6 +444,10 @@ type EmbeddedPostgresInstance = InstanceType; let embeddedPostgresCtorCache: EmbeddedPostgresCtor | null = null; function getEmbeddedPostgresCtor(): EmbeddedPostgresCtor { if (embeddedPostgresCtorCache) return embeddedPostgresCtorCache; + // FNXC:DesktopEmbeddedPostgres 2026-07-14-18:30: + // Patch asar binary paths before loading embedded-postgres so its module-level + // binary promise and later spawn/chmod use real unpacked executables. + installElectronAsarNativePathPatch(); // Use require() so the bundler leaves this as a runtime resolution (esbuild // keeps createRequire'd specifiers out of the static import graph). const mod = require("embedded-postgres") as { default: EmbeddedPostgresCtor }; @@ -265,23 +631,43 @@ function resolvePnpmPlatformPackageNativeRoot(packageName: string): string | nul } } -function resolveMacosEmbeddedPostgresNativeRoot(): string | null { - if (process.platform !== "darwin") return null; - const packageName = process.arch === "arm64" - ? "@embedded-postgres/darwin-arm64" - : process.arch === "x64" - ? "@embedded-postgres/darwin-x64" - : null; - if (!packageName) return null; +function embeddedPostgresPlatformPackageName( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): string | null { + if (platform === "darwin") { + if (arch === "arm64") return "@embedded-postgres/darwin-arm64"; + if (arch === "x64") return "@embedded-postgres/darwin-x64"; + return null; + } + if (platform === "linux") { + if (arch === "arm64") return "@embedded-postgres/linux-arm64"; + if (arch === "x64") return "@embedded-postgres/linux-x64"; + if (arch === "arm") return "@embedded-postgres/linux-arm"; + if (arch === "ia32") return "@embedded-postgres/linux-ia32"; + if (arch === "ppc64") return "@embedded-postgres/linux-ppc64"; + return null; + } + if (platform === "win32" && arch === "x64") return "@embedded-postgres/windows-x64"; + return null; +} +function resolveGenericEmbeddedPostgresNativeRoot(): string | null { + const packageName = embeddedPostgresPlatformPackageName(); + if (!packageName) return null; try { const entrypoint = require.resolve(packageName); - return join(dirname(entrypoint), "..", "native"); + return resolveElectronAsarUnpackedPath(join(dirname(entrypoint), "..", "native")); } catch { return resolvePnpmPlatformPackageNativeRoot(packageName); } } +function resolveMacosEmbeddedPostgresNativeRoot(): string | null { + if (process.platform !== "darwin") return null; + return resolveGenericEmbeddedPostgresNativeRoot(); +} + function normalizeBundledMacosDylibs(onLog: (message: string) => void): void { const nativeRoot = resolveMacosEmbeddedPostgresNativeRoot(); if (!nativeRoot) return; diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index bc55e5e566..fd8fdfe944 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -82,16 +82,57 @@ files: - node_modules/protobufjs/**/* - node_modules/tar-fs/**/* - node_modules/uuid/**/* + # FNXC:DesktopEmbeddedPostgres 2026-07-14-18:25: + # Zero-config desktop local mode boots embedded PostgreSQL via @fusion/core. + # These packages are required runtime deps of that path and must be in the + # explicit files allowlist so electron-builder cannot drop them when walking + # the staged production closure. + - node_modules/embedded-postgres/**/* + - node_modules/@embedded-postgres/**/* + - node_modules/pg/**/* + - node_modules/pg-connection-string/**/* + - node_modules/pg-int8/**/* + - node_modules/pg-pool/**/* + - node_modules/pg-protocol/**/* + - node_modules/pg-types/**/* + - node_modules/pgpass/**/* + - node_modules/postgres-array/**/* + - node_modules/postgres-bytea/**/* + - node_modules/postgres-date/**/* + - node_modules/postgres-interval/**/* + - node_modules/split2/**/* + - node_modules/xtend/**/* + - node_modules/postgres/**/* + - node_modules/async-exit-hook/**/* + # FNXC:DesktopOmpPlugin 2026-07-14-18:55: + # Dashboard server statically imports @fusion-plugin-examples/* runtime probes + # (hermes/openclaw/cursor/grok/omp/paperclip/droid/roadmap/dependency-graph). + # Include the whole scoped tree so a new statically-imported plugin cannot be + # dropped from the packaged app.asar when electron-builder walks the allowlist. + - node_modules/@fusion-plugin-examples/**/* + - node_modules/@fusion/plugin-sdk/**/* + - node_modules/@agentclientprotocol/sdk/**/* -# FNXC:DesktopEmbeddedPostgres 2026-07-14-09:30: -# Embedded Postgres launches native initdb/pg_ctl/postgres child processes. -# Executables and their shared libraries must live on the real filesystem; -# processes cannot execute payloads directly from Electron's virtual app.asar. +# FNXC:DesktopEmbeddedPostgres 2026-07-14-18:25: +# Embedded Postgres launches native initdb/pg_ctl/postgres child processes and resolves +# those paths from platform-package dist/index.js via import.meta.url. +# If only native/** is unpacked, dist/index.js stays inside app.asar so binary paths +# resolve as .../app.asar/.../native/bin/postgres. Electron marks those files unpacked, +# but fs.promises.chmod (used by embedded-postgres ensureBinIsExecutable) still fails +# with ENOTDIR against the asar path — packaged desktop local mode cannot start Postgres. +# Unpack the full platform packages (JS entrypoints + native trees) so __dirname is a +# real filesystem path under app.asar.unpacked and chmod/spawn work without asar redirects. +# Also keep the top-level embedded-postgres package unpacked so its dynamic import of the +# platform package resolves to the same real tree. asarUnpack: - - "node_modules/@embedded-postgres/*/native/**/*" + - "node_modules/@embedded-postgres/**/*" + - "node_modules/embedded-postgres/**/*" extraMetadata: - main: dist/main.js + # FNXC:DesktopEmbeddedPostgres 2026-07-14-18:50: + # CJS bootstrap patches child_process.spawn before ESM main loads so packaged + # embedded Postgres binaries resolve out of app.asar (see main-bootstrap.cjs). + main: dist/main-bootstrap.cjs extraResources: - from: src/icons diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f514ca27f4..d19b48f991 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -19,7 +19,7 @@ }, "private": true, "type": "module", - "main": "dist/main.js", + "main": "dist/main-bootstrap.cjs", "engines": { "node": ">=22.5.0" }, diff --git a/packages/desktop/scripts/build.ts b/packages/desktop/scripts/build.ts index cca612d2c5..e5c147f0ac 100644 --- a/packages/desktop/scripts/build.ts +++ b/packages/desktop/scripts/build.ts @@ -58,6 +58,17 @@ async function ensureDashboardBuild(): Promise { async function buildElectronEntrypoints(): Promise { console.log("[desktop:build] Bundling Electron main/preload with esbuild..."); + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:50: + * Ship the CJS bootstrap that patches child_process.spawn before the ESM main + * loads. package.json main + electron-builder extraMetadata point here so + * packaged local mode can execute embedded Postgres outside app.asar. + */ + await cp( + join(packageRoot, "src", "main-bootstrap.cjs"), + join(desktopDistDir, "main-bootstrap.cjs"), + ); + await Promise.all([ build({ entryPoints: [join(packageRoot, "src", "main.ts")], @@ -127,7 +138,8 @@ async function ensureEmbeddedRuntimeBuild(): Promise { // the Fusion backend". Assert the required files in BOTH the source `dist/` // (esbuild output) and the staged `deploy/dist/` (what electron-builder packs // into app.asar), since only the latter reflects what ships. -const REQUIRED_PACKAGED_ASSETS = ["main.js", "preload.js", join("client", "index.html")]; +// FNXC:DesktopEmbeddedPostgres 2026-07-14-18:50: main-bootstrap.cjs is the package entry; main.js is loaded from it. +const REQUIRED_PACKAGED_ASSETS = ["main-bootstrap.cjs", "main.js", "preload.js", join("client", "index.html")]; async function verifyPackagedArtifacts(): Promise { console.log("[desktop:build] Verifying required packaged assets are present..."); diff --git a/packages/desktop/scripts/workspace-tools.ts b/packages/desktop/scripts/workspace-tools.ts index 015f8d2382..bc8cd34176 100644 --- a/packages/desktop/scripts/workspace-tools.ts +++ b/packages/desktop/scripts/workspace-tools.ts @@ -70,20 +70,29 @@ async function buildPackage(relativePath: string): Promise { // condition kept for the bun-compiled CLI), so a missing dist => the packaged // app crashes on Local mode when @fusion/dashboard imports the plugin. // routes.ts / runtime-provider-probes.ts / droid-cli-probe.ts / roadmap-routes.ts -// pull hermes, openclaw, paperclip, cursor, grok, droid and roadmap; dependency-graph +// pull hermes, openclaw, paperclip, cursor, grok, droid, omp and roadmap; dependency-graph // backs a dashboard view. Keep this list in sync with dashboard's static plugin imports. +// +// FNXC:DesktopOmpPlugin 2026-07-14-18:55: +// Oh My Pi (omp) ACP runtime is imported by runtime-provider-probes.ts. Without +// building its dist, packaged Local mode boots Postgres then fails with +// ERR_MODULE_NOT_FOUND for @fusion-plugin-examples/omp-runtime/dist/index.js and +// falls back to the mode chooser. +export const DASHBOARD_RUNTIME_PLUGIN_PACKAGES = [ + "plugins/fusion-plugin-dependency-graph", + "plugins/fusion-plugin-hermes-runtime", + "plugins/fusion-plugin-openclaw-runtime", + "plugins/fusion-plugin-paperclip-runtime", + "plugins/fusion-plugin-cursor-runtime", + "plugins/fusion-plugin-grok-runtime", + "plugins/fusion-plugin-omp-runtime", + "plugins/fusion-plugin-droid-runtime", + "plugins/fusion-plugin-roadmap", +] as const; + export async function buildDashboardRuntimePlugins(): Promise { await buildPackage("packages/plugin-sdk"); - await Promise.all([ - buildPackage("plugins/fusion-plugin-dependency-graph"), - buildPackage("plugins/fusion-plugin-hermes-runtime"), - buildPackage("plugins/fusion-plugin-openclaw-runtime"), - buildPackage("plugins/fusion-plugin-paperclip-runtime"), - buildPackage("plugins/fusion-plugin-cursor-runtime"), - buildPackage("plugins/fusion-plugin-grok-runtime"), - buildPackage("plugins/fusion-plugin-droid-runtime"), - buildPackage("plugins/fusion-plugin-roadmap"), - ]); + await Promise.all(DASHBOARD_RUNTIME_PLUGIN_PACKAGES.map((relativePath) => buildPackage(relativePath))); } export async function buildDashboard(): Promise { diff --git a/packages/desktop/src/__tests__/build-bundling.test.ts b/packages/desktop/src/__tests__/build-bundling.test.ts index 54f32e6cdb..f3ba13a6ce 100644 --- a/packages/desktop/src/__tests__/build-bundling.test.ts +++ b/packages/desktop/src/__tests__/build-bundling.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + DASHBOARD_RUNTIME_PLUGIN_PACKAGES, requiredEmbeddedPostgresPackages, verifyEmbeddedPostgresPayloads, } from "../../scripts/workspace-tools"; @@ -26,6 +27,23 @@ describe("desktop Electron main bundling", () => { expect(buildScript).toContain("await cp(dashboardRegistryManifestSource, dashboardRegistryManifestDist)"); }); + it("builds every dashboard-static runtime plugin including omp before packaging", async () => { + /* + * FNXC:DesktopOmpPlugin 2026-07-14-18:55: + * runtime-provider-probes imports omp-runtime; missing dist crashes Local mode + * after Postgres boots. Keep the build list aligned with that import surface. + */ + expect(DASHBOARD_RUNTIME_PLUGIN_PACKAGES).toContain("plugins/fusion-plugin-omp-runtime"); + expect(DASHBOARD_RUNTIME_PLUGIN_PACKAGES).toContain("plugins/fusion-plugin-hermes-runtime"); + expect(DASHBOARD_RUNTIME_PLUGIN_PACKAGES).toContain("plugins/fusion-plugin-grok-runtime"); + expect(DASHBOARD_RUNTIME_PLUGIN_PACKAGES).toContain("plugins/fusion-plugin-cursor-runtime"); + + const workspaceTools = await readDesktopFile("scripts/workspace-tools.ts"); + expect(workspaceTools).toContain("buildDashboardRuntimePlugins"); + expect(workspaceTools).toContain("DASHBOARD_RUNTIME_PLUGIN_PACKAGES"); + expect(workspaceTools).toContain("fusion-plugin-omp-runtime"); + }); + it("externalizes production main-process packages so updater CJS deps are not bundled into ESM", async () => { const buildScript = await readDesktopFile("scripts/build.ts"); const mainBuildBlock = buildScript.match(/entryPoints: \[join\(packageRoot, "src", "main\.ts"\)\],[\s\S]*?logLevel: "info",/m)?.[0]; diff --git a/packages/desktop/src/__tests__/electron-builder-config.test.ts b/packages/desktop/src/__tests__/electron-builder-config.test.ts index e3bfa521a1..3639f5c25e 100644 --- a/packages/desktop/src/__tests__/electron-builder-config.test.ts +++ b/packages/desktop/src/__tests__/electron-builder-config.test.ts @@ -132,11 +132,20 @@ describe("electron-builder desktop config", () => { } }); - it("unpacks native embedded Postgres processes from app.asar", async () => { + it("unpacks full embedded Postgres packages from app.asar", async () => { + /* + * FNXC:DesktopEmbeddedPostgres 2026-07-14-18:25: + * Native-only asarUnpack left dist/index.js inside app.asar; binary paths then + * resolved under the archive and chmod/spawn failed on packaged desktop boots. + * Assert the full platform + parent packages are unpacked. + */ const builderConfig = await readDesktopFile("electron-builder.yml"); expect(builderConfig).toMatch( - /asarUnpack:\s*[\s\S]*?-\s*"node_modules\/@embedded-postgres\/\*\/native\/\*\*\/\*"/m, + /asarUnpack:\s*[\s\S]*?-\s*"node_modules\/@embedded-postgres\/\*\*\/\*"/m, + ); + expect(builderConfig).toMatch( + /asarUnpack:\s*[\s\S]*?-\s*"node_modules\/embedded-postgres\/\*\*\/\*"/m, ); }); @@ -162,6 +171,31 @@ describe("electron-builder desktop config", () => { "node_modules/minizlib/**/*", "node_modules/yallist/**/*", "node_modules/yaml/**/*", + // FNXC:DesktopEmbeddedPostgres 2026-07-14-18:25: local-mode Postgres boot deps + // FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: assert the full pg transitive + // allowlist (protocol/types/pgpass/codecs/split2/xtend) so dropping any glob + // fails this regression and cannot silently break packaged startup. + "node_modules/embedded-postgres/**/*", + "node_modules/@embedded-postgres/**/*", + "node_modules/pg/**/*", + "node_modules/pg-connection-string/**/*", + "node_modules/pg-int8/**/*", + "node_modules/pg-pool/**/*", + "node_modules/pg-protocol/**/*", + "node_modules/pg-types/**/*", + "node_modules/pgpass/**/*", + "node_modules/postgres-array/**/*", + "node_modules/postgres-bytea/**/*", + "node_modules/postgres-date/**/*", + "node_modules/postgres-interval/**/*", + "node_modules/split2/**/*", + "node_modules/xtend/**/*", + "node_modules/postgres/**/*", + "node_modules/async-exit-hook/**/*", + // FNXC:DesktopOmpPlugin 2026-07-14-18:55: dashboard-static plugin packages + "node_modules/@fusion-plugin-examples/**/*", + "node_modules/@fusion/plugin-sdk/**/*", + "node_modules/@agentclientprotocol/sdk/**/*", ]; for (const dependencyGlob of requiredRuntimeDependencyGlobs) { diff --git a/packages/desktop/src/main-bootstrap.cjs b/packages/desktop/src/main-bootstrap.cjs new file mode 100644 index 0000000000..6b184596ef --- /dev/null +++ b/packages/desktop/src/main-bootstrap.cjs @@ -0,0 +1,85 @@ +/* +FNXC:DesktopEmbeddedPostgres 2026-07-14-18:50: +Packaged Electron loads @fusion/core + embedded-postgres as ESM after app.asar is mounted. +Platform package entrypoints resolve native binaries via import.meta.url to +`.../app.asar/.../native/bin/postgres`, and spawn against that path fails with ENOTDIR. +CJS mutation of child_process.spawn must run BEFORE any ESM importer binds spawn. +This bootstrap is the package main: patch builtins, then dynamically import the ESM main. + +FNXC:DesktopEmbeddedPostgres 2026-07-15-03:11: +After CJS reassignment of child_process.spawn / fs.promises.stat|chmod, already-resolved +ESM named imports keep the pre-patch functions until module.syncBuiltinESMExports() runs. +Call it after every CJS mutation so the subsequent dynamic import of main.js (and any +ESM embedded-postgres import) observes the rewritten paths. +*/ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const cp = require("child_process"); +const fsp = require("fs/promises"); +const { syncBuiltinESMExports } = require("module"); + +const BIN_NAMES = new Set([ + "postgres", + "initdb", + "pg_ctl", + "postgres.exe", + "initdb.exe", + "pg_ctl.exe", +]); + +function runtimeBinRoot() { + return path.join(os.homedir(), ".fusion", "embedded-postgres", "runtime-bin", `${process.platform}-${process.arch}`); +} + +function resolveAsarUnpacked(filePath) { + if (!filePath || typeof filePath !== "string") return filePath; + const base = path.basename(filePath); + if (BIN_NAMES.has(base) && filePath.includes(`${path.sep}app.asar`)) { + const materialized = path.join(runtimeBinRoot(), "bin", base); + if (fs.existsSync(materialized)) return materialized; + } + if (filePath.includes(`${path.sep}app.asar.unpacked${path.sep}`)) return filePath; + const marker = `${path.sep}app.asar${path.sep}`; + const index = filePath.indexOf(marker); + if (index === -1) return filePath; + const unpacked = + filePath.slice(0, index) + + `${path.sep}app.asar.unpacked${path.sep}` + + filePath.slice(index + marker.length); + return fs.existsSync(unpacked) ? unpacked : filePath; +} + +function installSpawnPatch() { + const originalSpawn = cp.spawn.bind(cp); + cp.spawn = function patchedSpawn(command, ...rest) { + const fixed = typeof command === "string" ? resolveAsarUnpacked(command) : command; + return originalSpawn(fixed, ...rest); + }; + + const originalStat = fsp.stat.bind(fsp); + fsp.stat = function patchedStat(p, ...rest) { + const fixed = typeof p === "string" ? resolveAsarUnpacked(p) : p; + return originalStat(fixed, ...rest); + }; + + const originalChmod = fsp.chmod.bind(fsp); + fsp.chmod = function patchedChmod(p, ...rest) { + const fixed = typeof p === "string" ? resolveAsarUnpacked(p) : p; + return originalChmod(fixed, ...rest); + }; + + // Propagate CJS mutations to ESM named exports before main.js loads. + syncBuiltinESMExports(); +} + +installSpawnPatch(); + +// Load the ESM Electron main after builtins are patched. +const { pathToFileURL } = require("url"); +import(pathToFileURL(path.join(__dirname, "main.js")).href).catch((err) => { + console.error("[desktop/bootstrap] failed to load main.js", err); + process.exit(1); +});