fix(desktop): boot embedded Postgres in packaged app and ship omp dist (#2106)
## 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/<plat-arch>/`. - **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 | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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-"));
|
||||
|
||||
@@ -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<typeof createHash>,
|
||||
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/<platform>-<arch>`.
|
||||
*/
|
||||
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<EmbeddedPostgresCtor>;
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/main.js",
|
||||
"main": "dist/main-bootstrap.cjs",
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
},
|
||||
|
||||
@@ -58,6 +58,17 @@ async function ensureDashboardBuild(): Promise<void> {
|
||||
async function buildElectronEntrypoints(): Promise<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
console.log("[desktop:build] Verifying required packaged assets are present...");
|
||||
|
||||
@@ -70,20 +70,29 @@ async function buildPackage(relativePath: string): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
85
packages/desktop/src/main-bootstrap.cjs
Normal file
85
packages/desktop/src/main-bootstrap.cjs
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user