This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix(spawn): delegate to `pty` CLI when bundled

`spawnDaemon` previously did `spawn('node', [<__dirname>/server.js])`.
Under bundlers that virtualise the filesystem (`bun build --compile`,
esbuild single-file, etc.) `import.meta.url` resolves to a `bunfs:`-style
path the spawned `node` child can't read, and the daemon fails to start.
The first iteration of this PR addressed that with an embedded server-
source bundle materialised to `os.tmpdir()`. That worked for resolving
server.js itself but exposed a deeper failure mode: the materialised
file lives outside the consumer's `node_modules`, and ESM resolution
does not honour `NODE_PATH` — so the daemon can't find its own external
deps (`node-pty`, `@xterm/*`) either, and every consumer would need a
bespoke `node_modules`-symlink dance to recover.

Replace the embedded-source approach with CLI delegation. The resolution
strategy becomes:

1. `setServerModulePath()` override — wins over everything (test
harnesses, supervisors with custom paths).
2. Sibling `__dirname/server.js` readable on disk — direct
`node <server.js>` (existing fast path for ordinary npm installs).
3. Bundled context — sibling unreadable. Shell out to `pty run -d
--name <name> --cwd <cwd> [--isolate-env] [--tag k=v]... -- <cmd>
<args>`. The CLI binary is always a real on-disk file with intact
module resolution, so it sidesteps every bundling failure mode at
once: spawning, server materialisation, daemon module resolution,
native-binding loading.

Tradeoff: the CLI path doesn't surface every `SpawnDaemonOptions` field
(`rows`, `cols`, `displayCommand`, `displayName`, `ephemeral`,
`extraEnv`, `env`, `launcher`). For the dominant consumer pattern
(spawn a shell, attach a UI, resize after attach), only `cwd`, `name`,
`tags`, and `isolateEnv` are load-bearing at spawn time — all
supported. Add CLI flags upstream as concrete needs arise. Consumers
that need full fidelity in a bundled context can still call
`setServerModulePath()` with a real on-disk server.

Drops `scripts/embed-server-source.js`, the `dist/server-source.txt`
artifact, and the `esbuild` dev-dep that the embed pipeline required.
Tests cover all three resolution strategies (on-disk, CLI delegation,
explicit override) and the no-CLI-on-PATH error path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

authored by

schickling-assistant
Claude Opus 4.7 (1M context)
and committed by
Nathan Herald
(May 7, 2026, 11:02 AM +0200) cebe2d14 b9a07bb6

+249 -50
+1 -1
flake.nix
··· 29 29 30 30 # Generated from package-lock.json. 31 31 # Regenerate with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json 32 - npmDepsHash = "sha256-mbrHBmn5oHc3C+T3XimQbr9lHAWnM/gBe0OSb+2Tf6I="; 32 + npmDepsHash = "sha256-wchxdkrsGEIykFZWP0eHa2vKCpEYnj4rYKVUEhuw5j4="; 33 33 34 34 # node-pty has native code that needs these at build time 35 35 nativeBuildInputs = with pkgs; [ python3 pkg-config ];
-30
package-lock.json
··· 641 641 "arm64" 642 642 ], 643 643 "dev": true, 644 - "libc": [ 645 - "glibc" 646 - ], 647 644 "license": "MIT", 648 645 "optional": true, 649 646 "os": [ ··· 661 658 "arm64" 662 659 ], 663 660 "dev": true, 664 - "libc": [ 665 - "musl" 666 - ], 667 661 "license": "MIT", 668 662 "optional": true, 669 663 "os": [ ··· 681 675 "ppc64" 682 676 ], 683 677 "dev": true, 684 - "libc": [ 685 - "glibc" 686 - ], 687 678 "license": "MIT", 688 679 "optional": true, 689 680 "os": [ ··· 701 692 "s390x" 702 693 ], 703 694 "dev": true, 704 - "libc": [ 705 - "glibc" 706 - ], 707 695 "license": "MIT", 708 696 "optional": true, 709 697 "os": [ ··· 721 709 "x64" 722 710 ], 723 711 "dev": true, 724 - "libc": [ 725 - "glibc" 726 - ], 727 712 "license": "MIT", 728 713 "optional": true, 729 714 "os": [ ··· 741 726 "x64" 742 727 ], 743 728 "dev": true, 744 - "libc": [ 745 - "musl" 746 - ], 747 729 "license": "MIT", 748 730 "optional": true, 749 731 "os": [ ··· 1291 1273 "arm64" 1292 1274 ], 1293 1275 "dev": true, 1294 - "libc": [ 1295 - "glibc" 1296 - ], 1297 1276 "license": "MPL-2.0", 1298 1277 "optional": true, 1299 1278 "os": [ ··· 1315 1294 "arm64" 1316 1295 ], 1317 1296 "dev": true, 1318 - "libc": [ 1319 - "musl" 1320 - ], 1321 1297 "license": "MPL-2.0", 1322 1298 "optional": true, 1323 1299 "os": [ ··· 1339 1315 "x64" 1340 1316 ], 1341 1317 "dev": true, 1342 - "libc": [ 1343 - "glibc" 1344 - ], 1345 1318 "license": "MPL-2.0", 1346 1319 "optional": true, 1347 1320 "os": [ ··· 1363 1336 "x64" 1364 1337 ], 1365 1338 "dev": true, 1366 - "libc": [ 1367 - "musl" 1368 - ], 1369 1339 "license": "MPL-2.0", 1370 1340 "optional": true, 1371 1341 "os": [
+96 -19
src/spawn.ts
··· 1 - import { spawn, execFileSync } from "node:child_process"; 1 + import { spawn, spawnSync, execFileSync } from "node:child_process"; 2 2 import * as fs from "node:fs"; 3 3 import * as path from "node:path"; 4 4 import * as tty from "node:tty"; ··· 7 7 8 8 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 9 10 - /** Allow overriding the server module path (used by the bundled supervisor). */ 10 + /** Allow overriding the server module path (used by the bundled supervisor 11 + * and test harnesses). When set, takes precedence over both the on-disk 12 + * fast path and the CLI delegation fallback. Pass null/empty to clear. */ 11 13 let _serverModulePath: string | null = null; 12 - export function setServerModulePath(p: string): void { _serverModulePath = p; } 14 + export function setServerModulePath(p: string | null): void { 15 + _serverModulePath = p && p.length > 0 ? p : null; 16 + } 13 17 14 18 export interface SpawnDaemonOptions { 15 19 name: string; ··· 56 60 * launcher: { command: "/usr/local/bin/node" }, 57 61 * }); 58 62 * ``` 63 + * 64 + * Ignored when this lib delegates the spawn to the `pty` CLI (the 65 + * bundled-context fallback) — the CLI handles its own runtime selection. 59 66 */ 60 67 launcher?: { command: string; args?: string[] }; 61 68 /** Time in ms to wait for the daemon's Unix socket to appear before ··· 72 79 * for rationale. */ 73 80 export const DEFAULT_START_TIMEOUT_MS = 30_000; 74 81 82 + /** 83 + * Resolve which strategy to use for spawning a daemon. 84 + * 85 + * 1. If `setServerModulePath` was called, run `node <override>` with the 86 + * explicit path. Used by test harnesses that want a custom server. 87 + * 2. If our sibling `dist/server.js` is a real file on disk, run 88 + * `node <sibling>` directly — fast path for ordinary npm installs. 89 + * 3. Otherwise (consumer bundled this package into a single binary; 90 + * `import.meta.url` is virtualised; sibling lookup fails), delegate 91 + * to the `pty` CLI on PATH. The CLI is always a real on-disk binary 92 + * with intact module resolution, so it sidesteps every bundling 93 + * failure mode at once: spawning, embedded source materialisation, 94 + * child-process module resolution, native-binding loading. 95 + */ 96 + type SpawnStrategy = 97 + | { kind: "node"; serverModule: string } 98 + | { kind: "cli" }; 99 + 100 + function resolveSpawnStrategy(): SpawnStrategy { 101 + if (_serverModulePath !== null) return { kind: "node", serverModule: _serverModulePath }; 102 + const sibling = path.join(__dirname, "server.js"); 103 + try { 104 + if (fs.statSync(sibling).isFile()) return { kind: "node", serverModule: sibling }; 105 + } catch {} 106 + return { kind: "cli" }; 107 + } 108 + 75 109 export async function spawnDaemon(options: SpawnDaemonOptions): Promise<void> { 110 + const strategy = resolveSpawnStrategy(); 111 + if (strategy.kind === "cli") return spawnViaCli(options); 112 + return spawnViaNode(options, strategy.serverModule); 113 + } 114 + 115 + async function spawnViaNode(options: SpawnDaemonOptions, serverModule: string): Promise<void> { 76 116 const stdout = process.stdout as tty.WriteStream; 77 117 const rows = options.rows ?? stdout.rows ?? 24; 78 118 const cols = options.cols ?? stdout.columns ?? 80; 79 119 80 - const serverModule = _serverModulePath ?? path.join(__dirname, "server.js"); 81 120 const config = JSON.stringify({ 82 121 name: options.name, 83 122 command: options.command, ··· 102 141 env: { ...process.env, PTY_SERVER_CONFIG: config }, 103 142 }); 104 143 105 - // Capture stderr for better error reporting 106 144 let stderrOutput = ""; 107 - child.stderr?.on("data", (data: Buffer) => { 108 - stderrOutput += data.toString(); 109 - }); 145 + child.stderr?.on("data", (data: Buffer) => { stderrOutput += data.toString(); }); 110 146 111 - // Detect early daemon crash before the socket appears 112 147 let earlyExit = false; 113 148 let earlyExitCode: number | null = null; 114 - child.on("exit", (code) => { 115 - earlyExit = true; 116 - earlyExitCode = code; 117 - }); 149 + child.on("exit", (code) => { earlyExit = true; earlyExitCode = code; }); 118 150 119 - (child.stderr as any)?.unref?.(); 151 + (child.stderr as { unref?: () => void } | null)?.unref?.(); 120 152 child.unref(); 121 153 122 154 try { ··· 128 160 } 129 161 }); 130 162 } catch (err) { 131 - // Kill the orphaned daemon process so it doesn't leak 132 163 if (!earlyExit && child.pid) { 133 164 try { process.kill(child.pid, "SIGTERM"); } catch {} 134 165 } ··· 136 167 } 137 168 } 138 169 170 + /** 171 + * Bundled-context fallback: shell out to `pty run -d ...` on PATH. 172 + * 173 + * Only the inputs that the CLI surface today supports are passed through. 174 + * Options without a CLI-level equivalent (`rows`, `cols`, `displayCommand`, 175 + * `displayName`, `ephemeral`, `extraEnv`, `env`, `launcher`) are silently 176 + * ignored on this path — they're either non-load-bearing for typical 177 + * consumers (initial size; clients resize after attach) or rarely used 178 + * (`launcher`, advanced env shaping). Add CLI flags upstream as concrete 179 + * needs surface. 180 + * 181 + * `isolateEnv` maps to `--isolate-env`. `cwd` to `--cwd`. `tags` to 182 + * repeated `--tag k=v`. `name` to `--name`. The session command is 183 + * positional after `--`. 184 + */ 185 + function spawnViaCli(options: SpawnDaemonOptions): Promise<void> { 186 + const cliArgs: string[] = ["run", "-d", "--name", options.name]; 187 + if (options.cwd) cliArgs.push("--cwd", options.cwd); 188 + if (options.isolateEnv) cliArgs.push("--isolate-env"); 189 + if (options.tags) { 190 + for (const [k, v] of Object.entries(options.tags)) { 191 + cliArgs.push("--tag", `${k}=${v}`); 192 + } 193 + } 194 + cliArgs.push("--", options.command, ...options.args); 195 + 196 + const result = spawnSync("pty", cliArgs, { 197 + stdio: ["ignore", "pipe", "pipe"], 198 + encoding: "utf-8", 199 + }); 200 + if (result.error !== undefined) { 201 + const err = result.error as NodeJS.ErrnoException; 202 + if (err.code === "ENOENT") { 203 + throw new Error( 204 + `@myobie/pty: bundled-context spawn requires the \`pty\` CLI on PATH. ` + 205 + `Install @myobie/pty so its \`bin/pty\` is available, or call ` + 206 + `setServerModulePath() with a real on-disk server.js before spawnDaemon.`, 207 + ); 208 + } 209 + throw err; 210 + } 211 + if (result.status !== 0) { 212 + const stderr = (result.stderr ?? "").trim(); 213 + const stdout = (result.stdout ?? "").trim(); 214 + const detail = stderr || stdout || `exit ${result.status}`; 215 + throw new Error(`pty CLI failed: ${detail}`); 216 + } 217 + return waitForSocket(options.name, 3000); 218 + } 219 + 139 220 export function waitForSocket( 140 221 name: string, 141 222 timeoutMs: number, ··· 146 227 147 228 return new Promise((resolve, reject) => { 148 229 function check(): void { 149 - // Check for early daemon failure 150 230 try { 151 231 earlyCheck?.(); 152 232 } catch (e) { ··· 174 254 } 175 255 176 256 export function resolveCommand(cmd: string): string { 177 - // Already absolute — just verify it exists 178 257 if (path.isAbsolute(cmd)) { 179 258 if (!fs.existsSync(cmd)) { 180 259 throw new Error(`Command not found: ${cmd}`); ··· 182 261 return cmd; 183 262 } 184 263 185 - // Relative path (contains /) — resolve against cwd 186 264 if (cmd.includes("/")) { 187 265 const resolved = path.resolve(cmd); 188 266 if (!fs.existsSync(resolved)) { ··· 191 269 return resolved; 192 270 } 193 271 194 - // Bare command name — look up in PATH 195 272 try { 196 273 return execFileSync("which", [cmd], { encoding: "utf8" }).trim(); 197 274 } catch {
+152
tests/spawn-bundle-fallback.test.ts
··· 1 + import { describe, it, expect, afterEach, afterAll, beforeEach } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { queryStats } from "../src/client.ts"; 7 + import { spawnDaemon, setServerModulePath } from "../src/spawn.ts"; 8 + 9 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 + const projectRoot = path.join(__dirname, ".."); 11 + const distServer = path.join(projectRoot, "dist", "server.js"); 12 + const realPty = path.join(projectRoot, "bin", "pty"); 13 + 14 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-bundle-fallback-")); 15 + afterAll(() => { 16 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 17 + }); 18 + 19 + let bgPids: number[] = []; 20 + afterEach(() => { 21 + for (const pid of bgPids) { 22 + try { process.kill(pid, "SIGTERM"); } catch {} 23 + } 24 + bgPids = []; 25 + // Reset the override between cases so each test exercises its intended 26 + // strategy. 27 + setServerModulePath(null); 28 + }); 29 + 30 + let nameCounter = 0; 31 + function uniqueName(): string { 32 + return `bundle-fb${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 33 + } 34 + 35 + function makeSessionDir(): string { 36 + return fs.mkdtempSync(path.join(testRoot, "d-")); 37 + } 38 + 39 + function trackPid(dir: string, name: string): void { 40 + try { 41 + const pid = parseInt(fs.readFileSync(path.join(dir, `${name}.pid`), "utf-8").trim(), 10); 42 + if (Number.isFinite(pid)) bgPids.push(pid); 43 + } catch {} 44 + } 45 + 46 + describe("spawnDaemon strategy resolution", () => { 47 + beforeEach(() => { 48 + if (!fs.existsSync(distServer)) { 49 + throw new Error(`Missing ${distServer} — run \`npm run build\` first.`); 50 + } 51 + if (!fs.existsSync(realPty)) { 52 + throw new Error(`Missing ${realPty} — run \`npm install\` to set up bin/.`); 53 + } 54 + }); 55 + 56 + it("on-disk fast path: spawns when sibling server.js is real", async () => { 57 + // Default state — no override, sibling exists in dist/. Tests that the 58 + // happy path (`__dirname/server.js` readable) reaches a working daemon. 59 + setServerModulePath(distServer); 60 + const dir = makeSessionDir(); 61 + const name = uniqueName(); 62 + process.env.PTY_SESSION_DIR = dir; 63 + 64 + await spawnDaemon({ 65 + name, 66 + command: "/bin/sh", 67 + args: ["-c", "sleep 30"], 68 + displayCommand: "sh", 69 + cwd: dir, 70 + }); 71 + 72 + const stats = await queryStats(name); 73 + expect(stats.name).toBe(name); 74 + expect(stats.process.alive).toBe(true); 75 + trackPid(dir, name); 76 + }, 15000); 77 + 78 + it("CLI delegation: spawns via `pty run -d` when the sibling is unreadable", async () => { 79 + // Force the resolver onto the CLI path by pointing the override at a 80 + // bogus path the resolver will skip (empty string ↛ truthy), then the 81 + // sibling lookup proceeds. Under tsx, __dirname is src/ where there is 82 + // no server.js — so the sibling path fails statSync and the resolver 83 + // falls back to the CLI. The CLI delegation should produce a session 84 + // with the same external behavior. 85 + const dir = makeSessionDir(); 86 + const name = uniqueName(); 87 + process.env.PTY_SESSION_DIR = dir; 88 + // Put bin/pty on PATH so the spawnSync('pty', ...) finds it. 89 + const oldPath = process.env.PATH ?? ""; 90 + process.env.PATH = `${path.dirname(realPty)}:${oldPath}`; 91 + try { 92 + await spawnDaemon({ 93 + name, 94 + command: "/bin/sh", 95 + args: ["-c", "sleep 30"], 96 + displayCommand: "sh", 97 + cwd: dir, 98 + tags: { source: "test" }, 99 + }); 100 + } finally { 101 + process.env.PATH = oldPath; 102 + } 103 + 104 + const stats = await queryStats(name); 105 + expect(stats.name).toBe(name); 106 + expect(stats.process.alive).toBe(true); 107 + trackPid(dir, name); 108 + }, 15000); 109 + 110 + it("CLI delegation: clear error when `pty` CLI isn't on PATH", async () => { 111 + // Same forced-CLI-path setup, but with an empty PATH so spawnSync 112 + // returns ENOENT. Should surface the documented guidance. 113 + const dir = makeSessionDir(); 114 + const name = uniqueName(); 115 + process.env.PTY_SESSION_DIR = dir; 116 + const oldPath = process.env.PATH ?? ""; 117 + process.env.PATH = ""; 118 + try { 119 + await expect( 120 + spawnDaemon({ 121 + name, 122 + command: "/bin/sh", 123 + args: ["-c", "sleep 30"], 124 + displayCommand: "sh", 125 + cwd: dir, 126 + }), 127 + ).rejects.toThrow(/pty.*CLI.*PATH|setServerModulePath/); 128 + } finally { 129 + process.env.PATH = oldPath; 130 + } 131 + }, 5000); 132 + 133 + it("explicit setServerModulePath() override wins over on-disk + CLI", async () => { 134 + setServerModulePath(distServer); 135 + const dir = makeSessionDir(); 136 + const name = uniqueName(); 137 + process.env.PTY_SESSION_DIR = dir; 138 + 139 + await spawnDaemon({ 140 + name, 141 + command: "/bin/sh", 142 + args: ["-c", "sleep 30"], 143 + displayCommand: "sh", 144 + cwd: dir, 145 + }); 146 + 147 + const stats = await queryStats(name); 148 + expect(stats.name).toBe(name); 149 + expect(stats.process.alive).toBe(true); 150 + trackPid(dir, name); 151 + }, 15000); 152 + });