aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorPhysicistLeon <physicistleon@gmail.com>2026-10-11 19:36:56 +0300
committerGitHub <noreply@github.com>2026-10-11 22:06:56 +0530
commitff449d9f807bc2aa9b6d13aaa8602c37a6a72352 (patch)
treecec8af0acff219105562b1f5dde4c7fa1f14c273 /src
parent8d7a067c8d82a592bfb3dcacf05305c39550cbc6 (diff)
fix(daemon): resolve tsx from the filesystem when Node's resolver cannot see node_modules (#28)
Inside a Bun-compiled pi binary (pi.exe), createRequire().resolve("tsx/cli") is rooted at the embedded virtual filesystem and cannot see the extension's real node_modules, so browser_setup failed instantly. The resolver result is now checked on disk and, when it fails, the tsx CLI is found by walking up the node_modules chain. Under Bun the daemon runs on `node` from PATH (process.execPath is the pi binary itself); PI_BROWSER_NODE overrides it. Fixes #27 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/daemon/spawn.ts70
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;
};