diff options
Diffstat (limited to 'src/daemon/spawn.ts')
| -rw-r--r-- | src/daemon/spawn.ts | 70 |
1 files changed, 62 insertions, 8 deletions
diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts index ffba92d..cea8325 100644 --- a/src/daemon/spawn.ts +++ b/src/daemon/spawn.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { accessSync } from "node:fs"; import { access, unlink } from "node:fs/promises"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; @@ -80,19 +81,67 @@ export const isDaemonRunning = async (timeoutMs = 2_000): Promise<boolean> => { }); }; -// Node's own resolver, not a guessed `node_modules/.bin` path: pi installs every extension into -// one flat, hoisted tree, so tsx lands beside the package rather than inside it and a fixed -// `<pkg>/node_modules/.bin/tsx` lookup always misses. Resolving the CLI entry also means no shell, -// so the Windows `.cmd` shim and its argument quoting are gone with it. -export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => { - const daemonScript = join(moduleDir, "index.ts"); +// Walks upward like Node's own node_modules lookup. Exported with an injectable start so the +// hoisted layout pi uses can be exercised against a fixture instead of this checkout. +export const resolveTsxCliFromFilesystem = (startDir: string = moduleDir): string | null => { + let dir = startDir; + + while (true) { + const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs"); + try { + accessSync(candidate); + return candidate; + } catch { + // Keep walking toward the filesystem root. This mirrors Node's node_modules lookup + // without relying on createRequire().resolve(), which is broken for external modules + // loaded through jiti/static inside a Bun-compiled executable such as pi.exe. + } + + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +}; + +const resolveTsxCli = (): string | null => { try { - return { command: process.execPath, args: [requireFromHere.resolve("tsx/cli"), daemonScript] }; + const resolved = requireFromHere.resolve("tsx/cli"); + // A compiled runtime may theoretically return a virtual path that a separately spawned + // Node process cannot read. Only accept a resolver result that exists on the host FS. + accessSync(resolved); + return resolved; } catch { - return null; + return resolveTsxCliFromFilesystem(); } }; +export const daemonNodeExecutable = ( + env: NodeJS.ProcessEnv = process.env, + versions: Readonly<Record<string, string | undefined>> = process.versions, + platform: NodeJS.Platform = process.platform, + execPath: string = process.execPath, +): string => { + const override = env["PI_BROWSER_NODE"]; + if (override) return override; + + // Under normal Node keep the exact executable that launched pi/the extension. Under Bun, + // process.execPath is bun/pi.exe, not Node, so run the tsx CLI with the required Node runtime. + if ("bun" in versions) return platform === "win32" ? "node.exe" : "node"; + + return execPath; +}; + +export const daemonSpawnCommand = (): { readonly command: string; readonly args: ReadonlyArray<string> } | null => { + const daemonScript = join(moduleDir, "index.ts"); + const tsxCli = resolveTsxCli(); + if (!tsxCli) return null; + + return { + command: daemonNodeExecutable(), + args: [tsxCli, daemonScript], + }; +}; + export const spawnDaemon = (): ChildProcess | null => { const spec = daemonSpawnCommand(); if (!spec) return null; @@ -104,6 +153,11 @@ export const spawnDaemon = (): ChildProcess | null => { windowsHide: true, }); + // `spawn` reports a missing executable (a bare `node` that is not on PATH under a Bun host) + // asynchronously via `error`; without a listener that is an uncaught exception in pi. + // ensureDaemon's poll loop reports the failure as a timeout, which is what the user sees. + child.on("error", () => {}); + child.unref(); return child; }; |
