diff --git a/flake.nix b/flake.nix index 2867d5f..e767755 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,7 @@ # Generated from package-lock.json. # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json - npmDepsHash = "sha256-mbrHBmn5oHc3C+T3XimQbr9lHAWnM/gBe0OSb+2Tf6I="; + npmDepsHash = "sha256-wchxdkrsGEIykFZWP0eHa2vKCpEYnj4rYKVUEhuw5j4="; # node-pty has native code that needs these at build time nativeBuildInputs = with pkgs; [ python3 pkg-config ]; diff --git a/package-lock.json b/package-lock.json index 40b2b8e..e1cfa64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -641,9 +641,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -661,9 +658,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -681,9 +675,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -701,9 +692,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -721,9 +709,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -741,9 +726,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1291,9 +1273,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1315,9 +1294,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1339,9 +1315,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1363,9 +1336,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/spawn.ts b/src/spawn.ts index 817d4bb..5765377 100644 --- a/src/spawn.ts +++ b/src/spawn.ts @@ -1,4 +1,4 @@ -import { spawn, execFileSync } from "node:child_process"; +import { spawn, spawnSync, execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import * as tty from "node:tty"; @@ -7,9 +7,13 @@ import { getSocketPath } from "./sessions.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -/** Allow overriding the server module path (used by the bundled supervisor). */ +/** Allow overriding the server module path (used by the bundled supervisor + * and test harnesses). When set, takes precedence over both the on-disk + * fast path and the CLI delegation fallback. Pass null/empty to clear. */ let _serverModulePath: string | null = null; -export function setServerModulePath(p: string): void { _serverModulePath = p; } +export function setServerModulePath(p: string | null): void { + _serverModulePath = p && p.length > 0 ? p : null; +} export interface SpawnDaemonOptions { name: string; @@ -56,16 +60,51 @@ export interface SpawnDaemonOptions { * launcher: { command: "/usr/local/bin/node" }, * }); * ``` + * + * Ignored when this lib delegates the spawn to the `pty` CLI (the + * bundled-context fallback) — the CLI handles its own runtime selection. */ launcher?: { command: string; args?: string[] }; } +/** + * Resolve which strategy to use for spawning a daemon. + * + * 1. If `setServerModulePath` was called, run `node ` with the + * explicit path. Used by test harnesses that want a custom server. + * 2. If our sibling `dist/server.js` is a real file on disk, run + * `node ` directly — fast path for ordinary npm installs. + * 3. Otherwise (consumer bundled this package into a single binary; + * `import.meta.url` is virtualised; sibling lookup fails), delegate + * to the `pty` CLI on PATH. The CLI is always a real on-disk binary + * with intact module resolution, so it sidesteps every bundling + * failure mode at once: spawning, embedded source materialisation, + * child-process module resolution, native-binding loading. + */ +type SpawnStrategy = + | { kind: "node"; serverModule: string } + | { kind: "cli" }; + +function resolveSpawnStrategy(): SpawnStrategy { + if (_serverModulePath !== null) return { kind: "node", serverModule: _serverModulePath }; + const sibling = path.join(__dirname, "server.js"); + try { + if (fs.statSync(sibling).isFile()) return { kind: "node", serverModule: sibling }; + } catch {} + return { kind: "cli" }; +} + export async function spawnDaemon(options: SpawnDaemonOptions): Promise { + const strategy = resolveSpawnStrategy(); + if (strategy.kind === "cli") return spawnViaCli(options); + return spawnViaNode(options, strategy.serverModule); +} + +async function spawnViaNode(options: SpawnDaemonOptions, serverModule: string): Promise { const stdout = process.stdout as tty.WriteStream; const rows = options.rows ?? stdout.rows ?? 24; const cols = options.cols ?? stdout.columns ?? 80; - const serverModule = _serverModulePath ?? path.join(__dirname, "server.js"); const config = JSON.stringify({ name: options.name, command: options.command, @@ -90,21 +129,14 @@ export async function spawnDaemon(options: SpawnDaemonOptions): Promise { env: { ...process.env, PTY_SERVER_CONFIG: config }, }); - // Capture stderr for better error reporting let stderrOutput = ""; - child.stderr?.on("data", (data: Buffer) => { - stderrOutput += data.toString(); - }); + child.stderr?.on("data", (data: Buffer) => { stderrOutput += data.toString(); }); - // Detect early daemon crash before the socket appears let earlyExit = false; let earlyExitCode: number | null = null; - child.on("exit", (code) => { - earlyExit = true; - earlyExitCode = code; - }); + child.on("exit", (code) => { earlyExit = true; earlyExitCode = code; }); - (child.stderr as any)?.unref?.(); + (child.stderr as { unref?: () => void } | null)?.unref?.(); child.unref(); try { @@ -116,7 +148,6 @@ export async function spawnDaemon(options: SpawnDaemonOptions): Promise { } }); } catch (err) { - // Kill the orphaned daemon process so it doesn't leak if (!earlyExit && child.pid) { try { process.kill(child.pid, "SIGTERM"); } catch {} } @@ -124,6 +155,56 @@ export async function spawnDaemon(options: SpawnDaemonOptions): Promise { } } +/** + * Bundled-context fallback: shell out to `pty run -d ...` on PATH. + * + * Only the inputs that the CLI surface today supports are passed through. + * Options without a CLI-level equivalent (`rows`, `cols`, `displayCommand`, + * `displayName`, `ephemeral`, `extraEnv`, `env`, `launcher`) are silently + * ignored on this path — they're either non-load-bearing for typical + * consumers (initial size; clients resize after attach) or rarely used + * (`launcher`, advanced env shaping). Add CLI flags upstream as concrete + * needs surface. + * + * `isolateEnv` maps to `--isolate-env`. `cwd` to `--cwd`. `tags` to + * repeated `--tag k=v`. `name` to `--name`. The session command is + * positional after `--`. + */ +function spawnViaCli(options: SpawnDaemonOptions): Promise { + const cliArgs: string[] = ["run", "-d", "--name", options.name]; + if (options.cwd) cliArgs.push("--cwd", options.cwd); + if (options.isolateEnv) cliArgs.push("--isolate-env"); + if (options.tags) { + for (const [k, v] of Object.entries(options.tags)) { + cliArgs.push("--tag", `${k}=${v}`); + } + } + cliArgs.push("--", options.command, ...options.args); + + const result = spawnSync("pty", cliArgs, { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + if (result.error !== undefined) { + const err = result.error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + throw new Error( + `@myobie/pty: bundled-context spawn requires the \`pty\` CLI on PATH. ` + + `Install @myobie/pty so its \`bin/pty\` is available, or call ` + + `setServerModulePath() with a real on-disk server.js before spawnDaemon.`, + ); + } + throw err; + } + if (result.status !== 0) { + const stderr = (result.stderr ?? "").trim(); + const stdout = (result.stdout ?? "").trim(); + const detail = stderr || stdout || `exit ${result.status}`; + throw new Error(`pty CLI failed: ${detail}`); + } + return waitForSocket(options.name, 3000); +} + export function waitForSocket( name: string, timeoutMs: number, @@ -134,7 +215,6 @@ export function waitForSocket( return new Promise((resolve, reject) => { function check(): void { - // Check for early daemon failure try { earlyCheck?.(); } catch (e) { @@ -162,7 +242,6 @@ export function waitForSocket( } export function resolveCommand(cmd: string): string { - // Already absolute — just verify it exists if (path.isAbsolute(cmd)) { if (!fs.existsSync(cmd)) { throw new Error(`Command not found: ${cmd}`); @@ -170,7 +249,6 @@ export function resolveCommand(cmd: string): string { return cmd; } - // Relative path (contains /) — resolve against cwd if (cmd.includes("/")) { const resolved = path.resolve(cmd); if (!fs.existsSync(resolved)) { @@ -179,7 +257,6 @@ export function resolveCommand(cmd: string): string { return resolved; } - // Bare command name — look up in PATH try { return execFileSync("which", [cmd], { encoding: "utf8" }).trim(); } catch { diff --git a/tests/spawn-bundle-fallback.test.ts b/tests/spawn-bundle-fallback.test.ts new file mode 100644 index 0000000..19d1de9 --- /dev/null +++ b/tests/spawn-bundle-fallback.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, afterEach, afterAll, beforeEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { queryStats } from "../src/client.ts"; +import { spawnDaemon, setServerModulePath } from "../src/spawn.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.join(__dirname, ".."); +const distServer = path.join(projectRoot, "dist", "server.js"); +const realPty = path.join(projectRoot, "bin", "pty"); + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-bundle-fallback-")); +afterAll(() => { + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +}); + +let bgPids: number[] = []; +afterEach(() => { + for (const pid of bgPids) { + try { process.kill(pid, "SIGTERM"); } catch {} + } + bgPids = []; + // Reset the override between cases so each test exercises its intended + // strategy. + setServerModulePath(null); +}); + +let nameCounter = 0; +function uniqueName(): string { + return `bundle-fb${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; +} + +function makeSessionDir(): string { + return fs.mkdtempSync(path.join(testRoot, "d-")); +} + +function trackPid(dir: string, name: string): void { + try { + const pid = parseInt(fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(), 10); + if (Number.isFinite(pid)) bgPids.push(pid); + } catch {} +} + +describe("spawnDaemon strategy resolution", () => { + beforeEach(() => { + if (!fs.existsSync(distServer)) { + throw new Error(`Missing ${distServer} — run \`npm run build\` first.`); + } + if (!fs.existsSync(realPty)) { + throw new Error(`Missing ${realPty} — run \`npm install\` to set up bin/.`); + } + }); + + it("on-disk fast path: spawns when sibling server.js is real", async () => { + // Default state — no override, sibling exists in dist/. Tests that the + // happy path (`__dirname/server.js` readable) reaches a working daemon. + setServerModulePath(distServer); + const dir = makeSessionDir(); + const name = uniqueName(); + process.env.PTY_SESSION_DIR = dir; + + await spawnDaemon({ + name, + command: "/bin/sh", + args: ["-c", "sleep 30"], + displayCommand: "sh", + cwd: dir, + }); + + const stats = await queryStats(name); + expect(stats.name).toBe(name); + expect(stats.process.alive).toBe(true); + trackPid(dir, name); + }, 15000); + + it("CLI delegation: spawns via `pty run -d` when the sibling is unreadable", async () => { + // Force the resolver onto the CLI path by pointing the override at a + // bogus path the resolver will skip (empty string ↛ truthy), then the + // sibling lookup proceeds. Under tsx, __dirname is src/ where there is + // no server.js — so the sibling path fails statSync and the resolver + // falls back to the CLI. The CLI delegation should produce a session + // with the same external behavior. + const dir = makeSessionDir(); + const name = uniqueName(); + process.env.PTY_SESSION_DIR = dir; + // Put bin/pty on PATH so the spawnSync('pty', ...) finds it. + const oldPath = process.env.PATH ?? ""; + process.env.PATH = `${path.dirname(realPty)}:${oldPath}`; + try { + await spawnDaemon({ + name, + command: "/bin/sh", + args: ["-c", "sleep 30"], + displayCommand: "sh", + cwd: dir, + tags: { source: "test" }, + }); + } finally { + process.env.PATH = oldPath; + } + + const stats = await queryStats(name); + expect(stats.name).toBe(name); + expect(stats.process.alive).toBe(true); + trackPid(dir, name); + }, 15000); + + it("CLI delegation: clear error when `pty` CLI isn't on PATH", async () => { + // Same forced-CLI-path setup, but with an empty PATH so spawnSync + // returns ENOENT. Should surface the documented guidance. + const dir = makeSessionDir(); + const name = uniqueName(); + process.env.PTY_SESSION_DIR = dir; + const oldPath = process.env.PATH ?? ""; + process.env.PATH = ""; + try { + await expect( + spawnDaemon({ + name, + command: "/bin/sh", + args: ["-c", "sleep 30"], + displayCommand: "sh", + cwd: dir, + }), + ).rejects.toThrow(/pty.*CLI.*PATH|setServerModulePath/); + } finally { + process.env.PATH = oldPath; + } + }, 5000); + + it("explicit setServerModulePath() override wins over on-disk + CLI", async () => { + setServerModulePath(distServer); + const dir = makeSessionDir(); + const name = uniqueName(); + process.env.PTY_SESSION_DIR = dir; + + await spawnDaemon({ + name, + command: "/bin/sh", + args: ["-c", "sleep 30"], + displayCommand: "sh", + cwd: dir, + }); + + const stats = await queryStats(name); + expect(stats.name).toBe(name); + expect(stats.process.alive).toBe(true); + trackPid(dir, name); + }, 15000); +});