From 6b893f78ecf22e2b009ea35c46ee5d42a74291fa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 18:45:47 -0700 Subject: [PATCH] fix(cli): make the standalone fn binary boot PostgreSQL in both modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bun-compiled exe has been unbootable since the PG cutover: bun standalone binaries do no node_modules resolution, so the deliberately out-of-graph require("embedded-postgres") failed from /$bunfs, and readFile'd migration .sql files were never embedded, so even external DATABASE_URL mode died at schema init. - schema-applier: resolveMigrationsDir() — FUSION_MIGRATIONS_DIR env > module-relative dist/migrations (npm/desktop, unchanged) > execPath-relative migrations/ (standalone exe), probe-based. - embedded-lifecycle: require("embedded-postgres") first (npm/desktop untouched), falling back to a self-contained staged bundle at /runtime//embedded-postgres/dist/index.cjs (FUSION_EMBEDDED_PG_RUNTIME_DIR override) with the native initdb/pg_ctl/postgres payload beside it. - build.ts: stage dist/migrations plus the per-target embedded-postgres bundle + native payload (warn when a cross-target payload is absent on the host, mirroring desktop's verifyEmbeddedPostgresPayloads). - release.yml: package fn-cli--.tar.gz (binary + migrations + runtime + client) with sha256 per leg; prune staged payload files from the release-collection globs; bare fn-cli-* binaries still uploaded. E2E-verified on the compiled binary: embedded mode initdb→/api/health 200 database healthy; DATABASE_URL mode applied migrations 0000–0019 (109 tables). Core typecheck clean; schema-applier 58/58 and embedded-lifecycle 44/44 tests pass. Co-Authored-By: Claude Fable 5 --- .changeset/standalone-exe-postgres-boot.md | 7 + .github/workflows/release.yml | 53 +++++- packages/cli/build.ts | 166 ++++++++++++++++++ .../core/src/postgres/embedded-lifecycle.ts | 113 +++++++++++- packages/core/src/postgres/schema-applier.ts | 88 ++++++---- 5 files changed, 386 insertions(+), 41 deletions(-) create mode 100644 .changeset/standalone-exe-postgres-boot.md diff --git a/.changeset/standalone-exe-postgres-boot.md b/.changeset/standalone-exe-postgres-boot.md new file mode 100644 index 0000000000..dd4371d118 --- /dev/null +++ b/.changeset/standalone-exe-postgres-boot.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix the standalone `fn` binary failing to boot in both embedded-Postgres and DATABASE_URL modes. +category: fix +dev: Migrations now resolve via FUSION_MIGRATIONS_DIR > module-relative > execPath-relative; embedded-postgres ships as a self-contained bundle + native payload under runtime//embedded-postgres (override root with FUSION_EMBEDDED_PG_RUNTIME_DIR); releases add self-contained fn-cli-.tar.gz assets. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff56f2007b..f6d1c9309f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,47 @@ jobs: $hash = (Get-FileHash ${{ matrix.binary }} -Algorithm SHA256).Hash.ToLower() "$hash ${{ matrix.binary }}" | Out-File -Encoding ascii ${{ matrix.binary }}.sha256 + # FNXC:Release 2026-07-17-13:45: + # The bare binary alone cannot boot: the compiled exe resolves PostgreSQL + # migrations and the embedded-postgres runtime payload execPath-relative + # (see packages/core/src/postgres/schema-applier.ts and + # embedded-lifecycle.ts). Ship a self-contained tarball (binary + + # migrations/ + runtime//) per target so a downloaded release + # asset works out of the box. The bare binary + .sha256 continue to be + # uploaded unchanged so existing download links stay valid. + - name: Package release tarball + shell: bash + run: | + cd packages/cli/dist + BASE="${{ matrix.binary }}" + BASE="${BASE%.exe}" + PLAT="${{ matrix.target }}" + PLAT="${PLAT#bun-}" + STAGE="tarball-stage" + rm -rf "$STAGE" + mkdir -p "$STAGE/runtime" + cp "${{ matrix.binary }}" "$STAGE/" + if [ -d migrations ]; then + cp -R migrations "$STAGE/migrations" + else + echo "::warning::packages/cli/dist/migrations missing; tarball will lack schema migrations" + fi + if [ -d "runtime/$PLAT" ]; then + cp -R "runtime/$PLAT" "$STAGE/runtime/$PLAT" + else + echo "::warning::packages/cli/dist/runtime/$PLAT missing; tarball will lack native runtime assets" + fi + if [ -d client ]; then + cp -R client "$STAGE/client" + fi + tar -czf "$BASE.tar.gz" -C "$STAGE" . + rm -rf "$STAGE" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$BASE.tar.gz" > "$BASE.tar.gz.sha256" + else + shasum -a 256 "$BASE.tar.gz" > "$BASE.tar.gz.sha256" + fi + - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -117,6 +158,9 @@ jobs: path: | packages/cli/dist/${{ matrix.binary }} packages/cli/dist/${{ matrix.binary }}.sha256 + packages/cli/dist/*.tar.gz + packages/cli/dist/*.tar.gz.sha256 + packages/cli/dist/migrations/**/* packages/cli/dist/runtime/**/* # ── Build Windows desktop EXE artifacts ────────────────────────────── @@ -568,7 +612,14 @@ jobs: id: collect run: | mkdir release-files - find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} release-files/ \; + # FNXC:Release 2026-07-17-13:45: + # Prune the CLI runtime/ and migrations/ staging trees: they exist in the + # artifact only as tarball inputs and contain files that would otherwise + # match the flat collection globs (e.g. embedded-postgres postgres.exe). + # The self-contained fn-cli-.tar.gz (+ .sha256) matches the + # existing *.tar.gz globs and reaches the release alongside the bare + # fn-cli-* binaries. + find artifacts \( -path "*/runtime/*" -o -path "*/migrations/*" \) -prune -o -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -print -exec cp {} release-files/ \; ls -la release-files/ count=$(find release-files -type f | wc -l | tr -d ' ') echo "count=$count" >> "$GITHUB_OUTPUT" diff --git a/packages/cli/build.ts b/packages/cli/build.ts index c75a9abf3c..965d120560 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -212,6 +212,165 @@ function ensureClientAssets(): ClientAssetMode { return "stub"; } +/* +FNXC:StandaloneExeMigrations 2026-07-17-13:40: +The compiled binary cannot read module-relative assets out of /$bunfs, so the +PostgreSQL migrations must ship as real files next to the binary. Stage +packages/core/src/postgres/migrations (same source tsup.config.ts stages into +dist/migrations for the npm package) into the exe output dir; core's +schema-applier resolves them execPath-relative at runtime. +*/ +const pgMigrationsSrc = join(workspaceRoot, "packages", "core", "src", "postgres", "migrations"); +const pgMigrationsDest = join(outDir, "migrations"); + +function stageMigrations(): void { + if (!existsSync(pgMigrationsSrc)) { + console.warn( + `WARNING: PostgreSQL migrations source not found at ${pgMigrationsSrc}; the standalone binary will fail to apply schema migrations.`, + ); + return; + } + if (existsSync(pgMigrationsDest)) { + rmSync(pgMigrationsDest, { recursive: true, force: true }); + } + cpSync(pgMigrationsSrc, pgMigrationsDest, { recursive: true }); + console.log(` → ${pgMigrationsDest}`); +} + +// ── Embedded PostgreSQL runtime staging ─────────────────────────────── +/* +FNXC:StandaloneExeEmbeddedPg 2026-07-17-14:20: +core's embedded-lifecycle loads `embedded-postgres` via createRequire at +runtime (deliberately outside the bundler graph). Inside the compiled binary +that resolution fails: bun --compile binaries perform NO node_modules +bare-specifier resolution at runtime — not even through a createRequire +anchored at a real on-disk directory (verified empirically: requiring an +absolute path works, but any bare import like "pg" from that file then fails). +A staged node_modules tree therefore cannot work. Instead, stage a fully +self-contained esbuild CJS bundle of embedded-postgres (pg, async-exit-hook, +and the matching @embedded-postgres/ entry inlined) at + dist/runtime//embedded-postgres/dist/index.cjs +plus the native initdb/pg_ctl/postgres payload at + dist/runtime//embedded-postgres/native/ +The platform package resolves its binaries via import.meta.url ("../native/ +bin/..."), so import.meta.url is defined to the bundle's own file URL and the +native tree is staged one level up — the same relative layout the package +expects. embedded-lifecycle probes this execPath-relative dir (or +FUSION_EMBEDDED_PG_RUNTIME_DIR) only when normal resolution fails. +pnpm-workspace.yaml supportedArchitectures limits local installs to the host +OS, so targets whose platform payload is absent on the build host get a +warning and no embedded payload (DATABASE_URL mode is unaffected), mirroring +the spirit of verifyEmbeddedPostgresPayloads in +packages/desktop/scripts/workspace-tools.ts. +*/ +const coreRequire = createRequire(join(workspaceRoot, "packages", "core", "package.json")); + +const ALL_EMBEDDED_PG_PLATFORM_PACKAGES = [ + "@embedded-postgres/darwin-arm64", + "@embedded-postgres/darwin-x64", + "@embedded-postgres/linux-arm64", + "@embedded-postgres/linux-x64", + "@embedded-postgres/linux-arm", + "@embedded-postgres/linux-ia32", + "@embedded-postgres/linux-ppc64", + "@embedded-postgres/windows-x64", +] as const; + +/** Map a runtime prebuild name (e.g. "darwin-arm64", "windows-x64") to the platform package. */ +function embeddedPgPlatformPackageFor(prebuildName: string): string | null { + const [plat, arch] = prebuildName.split("-"); + const os = plat === "windows" || plat === "win32" ? "windows" : plat; + const name = `@embedded-postgres/${os}-${arch}`; + return (ALL_EMBEDDED_PG_PLATFORM_PACKAGES as readonly string[]).includes(name) ? name : null; +} + +function stageEmbeddedPostgresRuntime(target?: BunTarget): boolean { + const prebuildName = target ? targetToPrebuildName(target) : hostPrebuildName(); + const destRoot = join(runtimeDir, prebuildName, "embedded-postgres"); + try { + if (existsSync(destRoot)) { + rmSync(destRoot, { recursive: true, force: true }); + } + mkdirSync(join(destRoot, "dist"), { recursive: true }); + + let embeddedPgJsonPath: string; + try { + embeddedPgJsonPath = coreRequire.resolve("embedded-postgres/package.json"); + } catch { + console.warn( + ` WARNING: embedded-postgres is not resolvable from @fusion/core; the ${prebuildName} binary will not support the default embedded database mode.`, + ); + return false; + } + const embeddedPgRoot = dirname(embeddedPgJsonPath); + const embeddedPgEntry = join(embeddedPgRoot, "dist", "index.js"); + const embeddedPgRequire = createRequire(embeddedPgJsonPath); + + // Resolve the target's native payload (an optionalDependency of + // embedded-postgres, resolved from its own location). Absent payloads are + // a warning, not a failure — pnpm only installs the host OS's packages. + const platformPkg = embeddedPgPlatformPackageFor(prebuildName); + let nativeSrc: string | null = null; + if (platformPkg) { + try { + const platformEntry = embeddedPgRequire.resolve(platformPkg); + const candidate = join(dirname(platformEntry), "..", "native"); + if (existsSync(join(candidate, "bin"))) nativeSrc = candidate; + } catch { + nativeSrc = null; + } + } + if (!platformPkg || !nativeSrc) { + console.warn( + ` WARNING: embedded-postgres native payload (${platformPkg ?? "unmapped platform"}) is not installed on this host for target ${prebuildName}. ` + + `Embedded database mode will be unavailable in this build (DATABASE_URL mode is unaffected).`, + ); + } + + // Bundle embedded-postgres + deps into one self-contained CJS file. The + // target's platform package is inlined; the other platforms' dynamic + // imports stay external (their branches never execute at runtime). + const esbuildBin = join(workspaceRoot, "node_modules", ".bin", process.platform === "win32" ? "esbuild.cmd" : "esbuild"); + if (!existsSync(esbuildBin)) { + console.warn(` WARNING: esbuild not found at ${esbuildBin}; cannot stage embedded-postgres runtime.`); + return false; + } + const externals = ALL_EMBEDDED_PG_PLATFORM_PACKAGES.filter( + (name) => !(nativeSrc && name === platformPkg), + ); + const outFile = join(destRoot, "dist", "index.cjs"); + const esbuildArgs = [ + embeddedPgEntry, + "--bundle", + "--platform=node", + "--format=cjs", + `--outfile=${outFile}`, + // The inlined platform package computes native binary paths from + // import.meta.url; point it at the bundle's own real file location. + "--define:import.meta.url=__fusionEmbeddedPgBundleUrl", + "--banner:js=const __fusionEmbeddedPgBundleUrl = require('node:url').pathToFileURL(__filename).href;", + "--external:pg-native", + ...externals.map((name) => `--external:${name}`), + "--log-level=warning", + ]; + const bundleProc = Bun.spawnSync({ cmd: [esbuildBin, ...esbuildArgs], cwd: workspaceRoot, stdout: "inherit", stderr: "inherit" }); + if (bundleProc.exitCode !== 0) { + console.error(` ERROR: esbuild bundling of embedded-postgres failed for ${prebuildName} (exit ${bundleProc.exitCode}).`); + return false; + } + + if (nativeSrc) { + // Preserve symlinks (macOS dylib ABI-compat links) and executable bits. + cpSync(nativeSrc, join(destRoot, "native"), { recursive: true }); + } + console.log(` → ${destRoot} (embedded-postgres runtime bundle${nativeSrc ? " + native payload" : ", JS only"})`); + return nativeSrc !== null; + } catch (err) { + console.error(` ERROR: Failed to stage embedded-postgres runtime for ${prebuildName}:`, err); + return false; + } +} + // ── Copy native terminal assets for a specific target ───────────────── /** * Stage @homebridge/node-pty-prebuilt-multiarch native assets for the given target. @@ -325,6 +484,10 @@ function compileBinary(outFile: string, target: string, isCrossCompile: boolean) ? target.replace(/^bun-/, "") : hostPrebuildName(); copyNativeAssets(isCrossCompile ? target as BunTarget : undefined); + // FNXC:StandaloneExeEmbeddedPg 2026-07-17-13:40: + // Must run AFTER copyNativeAssets — that function recreates runtime// + // and would wipe a previously staged embedded-postgres tree. + stageEmbeddedPostgresRuntime(isCrossCompile ? target as BunTarget : undefined); // Prepare asset paths for embedding const nativeAssetDir = join(runtimeDir, prebuildName); @@ -382,6 +545,9 @@ const { targets } = parseArgs(); // Stage assets once (shared across all binaries) const clientAssetMode = ensureClientAssets(); +// FNXC:StandaloneExeMigrations 2026-07-17-13:40: +// PostgreSQL migrations ship next to the binary (platform-independent, staged once). +stageMigrations(); if (targets === null) { // Default: build for current platform → dist/fn diff --git a/packages/core/src/postgres/embedded-lifecycle.ts b/packages/core/src/postgres/embedded-lifecycle.ts index 49652d29c9..f81083469a 100644 --- a/packages/core/src/postgres/embedded-lifecycle.ts +++ b/packages/core/src/postgres/embedded-lifecycle.ts @@ -84,6 +84,107 @@ export { isWindowsElevatedAdmin } from "./embedded-windows-admin.js"; const require = createRequire(import.meta.url); +/* +FNXC:StandaloneExeEmbeddedPg 2026-07-17-14:25: +Inside the bun-compiled standalone `fn` binary, `createRequire(import.meta.url)` +is anchored at the virtual /$bunfs/root filesystem, so the deliberate +out-of-bundle `require("embedded-postgres")` fails with "Cannot find package +'embedded-postgres'" and default embedded-PG boot dies. Worse, bun --compile +binaries perform NO node_modules bare-specifier resolution at runtime at all +(verified: requiring an absolute file path works, but that file's own bare +imports like "pg" then fail), so a staged node_modules tree cannot help. +The standalone build (packages/cli/build.ts) therefore stages a fully +self-contained CJS bundle of embedded-postgres (pg and the +@embedded-postgres/ entry inlined) plus the native +initdb/pg_ctl/postgres payload at + /runtime/-/embedded-postgres/ + dist/index.cjs — the bundle, loaded below by absolute path + native/bin/... — binaries the bundle resolves via ../native/ +These helpers locate that staged bundle (or FUSION_EMBEDDED_PG_RUNTIME_DIR +when the operator relocates the payload). Resolution order is strictly +additive: the normal createRequire path is tried first, so npm/tsup and +desktop installs are untouched; the staged bundle is consulted only when the +primary resolution fails. +*/ +function embeddedPostgresStagedRootCandidates(): string[] { + const roots: string[] = []; + const envRoot = process.env.FUSION_EMBEDDED_PG_RUNTIME_DIR; + if (envRoot) roots.push(envRoot); + try { + const execDir = dirname(process.execPath); + // build.ts stages per-target dirs named after the bun target suffix. For + // darwin/linux that equals `${process.platform}-${process.arch}`; Windows + // cross-compiled release assets use "windows-x64" while process.platform is + // "win32", so probe both spellings. + roots.push(join(execDir, "runtime", `${process.platform}-${process.arch}`, "embedded-postgres")); + if (process.platform === "win32") { + roots.push(join(execDir, "runtime", `windows-${process.arch}`, "embedded-postgres")); + } + } catch { + // process.execPath is always defined in practice; keep the fallback silent. + } + return roots; +} + +let stagedEmbeddedPgBundleCache: string | null | undefined; +/** Absolute path of the staged self-contained embedded-postgres bundle, or null. */ +function getStagedEmbeddedPgBundlePath(): string | null { + if (stagedEmbeddedPgBundleCache !== undefined) return stagedEmbeddedPgBundleCache; + stagedEmbeddedPgBundleCache = null; + for (const root of embeddedPostgresStagedRootCandidates()) { + try { + const bundle = join(root, "dist", "index.cjs"); + if (existsSync(bundle)) { + stagedEmbeddedPgBundleCache = bundle; + break; + } + } catch { + // Keep probing the remaining candidates. + } + } + return stagedEmbeddedPgBundleCache; +} + +/** + * require("embedded-postgres") with the staged-standalone fallback (loaded by + * absolute path — the only require form a compiled bun binary can resolve + * outside its own bundle). Primary resolution always wins. + */ +function requireEmbeddedPostgresModule(): unknown { + try { + return require("embedded-postgres"); + } catch (primaryError) { + const bundle = getStagedEmbeddedPgBundlePath(); + if (bundle) { + try { + return require(bundle); + } catch { + // Fall through and surface the primary (normal-install) error. + } + } + throw primaryError; + } +} + +/** + * require.resolve() for embedded-postgres-family specifiers with the + * staged-standalone fallback. The staged bundle inlines the platform package, + * so every family specifier resolves to the bundle path — which preserves the + * layout contract callers rely on: join(dirname(entrypoint), "..", "native") + * lands on the staged native/ tree. + */ +function resolveEmbeddedPostgresSpecifier(specifier: string): string { + try { + return require.resolve(specifier); + } catch (primaryError) { + const bundle = getStagedEmbeddedPgBundlePath(); + if (bundle && (specifier === "embedded-postgres" || specifier.startsWith("@embedded-postgres/"))) { + return bundle; + } + throw primaryError; + } +} + const EMBEDDED_PG_BIN_NAMES = new Set([ "postgres", "initdb", @@ -505,7 +606,10 @@ function getEmbeddedPostgresCtor(): EmbeddedPostgresCtor { 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 }; + // FNXC:StandaloneExeEmbeddedPg 2026-07-17-14:25: + // requireEmbeddedPostgresModule adds the execPath-relative staged-bundle + // fallback used by the bun standalone binary; normal installs resolve as before. + const mod = requireEmbeddedPostgresModule() as { default: EmbeddedPostgresCtor }; embeddedPostgresCtorCache = mod.default ?? (mod as unknown as EmbeddedPostgresCtor); return embeddedPostgresCtorCache; } @@ -684,7 +788,7 @@ function findPnpmVirtualStore(start: string): string | null { function resolvePnpmPlatformPackageNativeRoot(packageName: string): string | null { try { - const embeddedEntrypoint = require.resolve("embedded-postgres"); + const embeddedEntrypoint = resolveEmbeddedPostgresSpecifier("embedded-postgres"); const virtualStore = findPnpmVirtualStore(dirname(embeddedEntrypoint)); if (!virtualStore) return null; const encodedName = packageName.replace("/", "+"); @@ -722,7 +826,10 @@ function resolveGenericEmbeddedPostgresNativeRoot(): string | null { const packageName = embeddedPostgresPlatformPackageName(); if (!packageName) return null; try { - const entrypoint = require.resolve(packageName); + // FNXC:StandaloneExeEmbeddedPg 2026-07-17-13:35: + // Staged-standalone fallback included so the native initdb/pg_ctl/postgres + // payload resolves from the runtime dir shipped next to the fn binary. + const entrypoint = resolveEmbeddedPostgresSpecifier(packageName); return resolveElectronAsarUnpackedPath(join(dirname(entrypoint), "..", "native")); } catch { return resolvePnpmPlatformPackageNativeRoot(packageName); diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index f51600030f..5b0f339a35 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -20,6 +20,7 @@ */ import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; @@ -107,94 +108,107 @@ export const BULK_COMPLETION_REFUSAL_AT_VERSION = "0018"; export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const BASELINE_MIGRATION_PATH = join(__dirname, "migrations", "0000_initial.sql"); + +/* +FNXC:StandaloneExeMigrations 2026-07-17-13:30: +The bun-compiled standalone `fn` binary runs its bundled code from the virtual +/$bunfs/root filesystem, so the historical `join(__dirname, "migrations")` +resolution points at a path that does not exist on disk (bun --compile does not +embed readFile assets) and every DATABASE_URL boot died with +ENOENT /$bunfs/root/migrations/0000_initial.sql. Resolution order: + 1. FUSION_MIGRATIONS_DIR env override — always wins when set (operator escape hatch). + 2. join(__dirname, "migrations") — the npm/tsup and desktop layout; kept first + among the probes so nothing changes for existing installs. + 3. join(dirname(process.execPath), "migrations") — the standalone-exe layout, + where build.ts / the release tarball stage migrations/ next to the binary. +The existsSync probe (not a runtime-detection heuristic) picks between (2) and +(3): inside the compiled binary the module-relative dir simply does not exist, +while for node-based installs it always does, so npm/desktop behavior is untouched. +*/ +function resolveMigrationsDir(): string { + const envDir = process.env.FUSION_MIGRATIONS_DIR; + if (envDir) return envDir; + const moduleDir = join(__dirname, "migrations"); + if (existsSync(join(moduleDir, "0000_initial.sql"))) return moduleDir; + const execDir = join(dirname(process.execPath), "migrations"); + if (existsSync(join(execDir, "0000_initial.sql"))) return execDir; + // Preserve the historical default (and its historical error message) when + // neither location exists — the readFile ENOENT remains the diagnostic. + return moduleDir; +} + +const MIGRATIONS_DIR = resolveMigrationsDir(); +const BASELINE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0000_initial.sql"); const AUTOMATION_ISOLATION_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0001_automation_project_isolation.sql", ); const ANALYTICS_ISOLATION_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0002_analytics_project_isolation.sql", ); const MONITOR_APPROVAL_ISOLATION_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0003_monitor_approval_project_isolation.sql", ); const LEGACY_CUTOVER_PRESERVATION_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0004_legacy_cutover_preservation.sql", ); const MULTI_PROJECT_CUTOVER_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0005_multi_project_cutover.sql", ); const PROJECT_OWNERSHIP_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0006_project_ownership.sql", ); const SQLITE_SCHEMA_PARITY_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0007_sqlite_schema_parity.sql", ); const SESSION_ADVISOR_ENABLED_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0008_session_advisor_enabled.sql", ); const MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0009_mission_fix_idempotency.sql", ); const IMPORT_TRANSLATION_CACHE_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0010_import_translation_cache.sql", ); const IMPORT_TRANSLATION_CACHE_SCOPE_FIX_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0016_import_translation_cache_scope_fix.sql", ); const IMPORT_TRANSLATION_CACHE_LEGACY_PARTITION_BACKFILL_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0019_import_translation_cache_legacy_partition_backfill.sql", ); const OWNER_PROJECT_ID_SPLIT_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0011_owner_project_id.sql", ); const CHAT_SESSION_PINS_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0012_chat_session_pins.sql", ); const EXECUTOR_TOOL_FAILURE_RETRY_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0013_executor_tool_failure_retry.sql", ); const EXECUTOR_ESCALATION_ATTEMPT_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0014_executor_escalation_attempt.sql", ); const GLOBAL_ROUTINES_MIGRATION_PATH = join( - __dirname, - "migrations", + MIGRATIONS_DIR, "0015_global_routines.sql", ); -const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(__dirname, "migrations", "0017_task_merger_model_lane.sql"); -const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(__dirname, "migrations", "0018_bulk_completion_refusal_at.sql"); +const TASK_MERGER_MODEL_LANE_MIGRATION_PATH = join(MIGRATIONS_DIR, "0017_task_merger_model_lane.sql"); +const BULK_COMPLETION_REFUSAL_AT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0018_bulk_completion_refusal_at.sql"); /** * Ensure the migration bookkeeping table exists. Lives in the public schema so