This repository has no description
0

Configure Feed

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

fix: handle invalid cwd errors explicitly (#10)

authored by

Johannes Schickling and committed by
GitHub
(Apr 9, 2026, 11:07 AM +0200) 63be2b34 46519b72

+120 -2
+5 -1
src/cli.ts
··· 17 17 type SessionInfo, 18 18 } from "./sessions.ts"; 19 19 import { spawnDaemon, resolveCommand } from "./spawn.ts"; 20 - import { runInteractive } from "./tui/interactive.ts"; 21 20 import { EventFollower, readRecentEvents, formatEvent } from "./events.ts"; 21 + 22 + const runInteractive = async (): Promise<void> => { 23 + const { runInteractive } = await import("./tui/interactive.ts"); 24 + await runInteractive(); 25 + }; 22 26 23 27 function usage(): void { 24 28 console.log(`Usage:
+35 -1
src/server.ts
··· 74 74 } 75 75 } 76 76 77 + function describeInvalidCwd(cwd: string): string | undefined { 78 + if (cwd.length === 0) return "Working directory is empty."; 79 + 80 + let stats: fs.Stats; 81 + try { 82 + stats = fs.statSync(cwd); 83 + } catch (err: any) { 84 + if (err?.code === "ENOENT") { 85 + return `Working directory does not exist: ${cwd}`; 86 + } 87 + return `Working directory is not accessible: ${cwd} (${err?.message ?? String(err)})`; 88 + } 89 + 90 + if (!stats.isDirectory()) { 91 + return `Working directory is not a directory: ${cwd}`; 92 + } 93 + 94 + try { 95 + fs.accessSync(cwd, fs.constants.X_OK); 96 + } catch { 97 + return `Working directory is not searchable: ${cwd}`; 98 + } 99 + 100 + return undefined; 101 + } 102 + 77 103 export class PtyServer { 78 104 private terminal: Terminal; 79 105 private serialize: SerializeAddon; ··· 220 246 const childEnv = { ...process.env }; 221 247 delete childEnv.PTY_SERVER_CONFIG; 222 248 childEnv.PTY_SESSION = options.name; 249 + 250 + const invalidCwd = describeInvalidCwd(options.cwd); 251 + if (invalidCwd !== undefined) { 252 + throw new Error( 253 + `${invalidCwd}\nCannot start session "${options.name}" for command "${options.command}".` 254 + ); 255 + } 256 + 223 257 try { 224 258 this.ptyProcess = pty.spawn( 225 259 "/bin/sh", ··· 236 270 const msg = err?.message ?? String(err); 237 271 if (msg.includes("posix_spawnp") || msg.includes("spawn")) { 238 272 throw new Error( 239 - `Failed to spawn "${options.command}": ${msg}\nIs the command installed and executable?` 273 + `Failed to spawn PTY shell "/bin/sh" for command "${options.command}" in cwd "${options.cwd}": ${msg}` 240 274 ); 241 275 } 242 276 throw err;
+80
tests/spawn-options.test.ts
··· 82 82 throw new Error(`Timeout waiting for daemon socket: ${socketPath}`); 83 83 } 84 84 85 + async function startDaemonExpectFailure( 86 + sessionDir: string, 87 + name: string, 88 + cwd: string, 89 + command = "cat", 90 + args: string[] = [], 91 + ): Promise<{ exitCode: number | null; stderr: string }> { 92 + const config = JSON.stringify({ 93 + name, 94 + command, 95 + args, 96 + displayCommand: command, 97 + cwd, 98 + rows: 24, 99 + cols: 80, 100 + }); 101 + 102 + const child = spawn(nodeBin, [serverModule], { 103 + stdio: ["ignore", "ignore", "pipe"], 104 + env: { 105 + ...process.env, 106 + PTY_SERVER_CONFIG: config, 107 + PTY_SESSION_DIR: sessionDir, 108 + }, 109 + }); 110 + 111 + let stderr = ""; 112 + child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); 113 + 114 + const exitCode = await new Promise<number | null>((resolve, reject) => { 115 + const timer = setTimeout(() => reject(new Error("Timed out waiting for daemon failure")), 5000); 116 + child.on("exit", (code) => { 117 + clearTimeout(timer); 118 + resolve(code); 119 + }); 120 + }); 121 + 122 + return { exitCode, stderr }; 123 + } 124 + 85 125 afterEach(() => { 86 126 for (const pid of bgPids) { 87 127 try { process.kill(pid, "SIGTERM"); } catch {} ··· 148 188 const stats = await queryStats(name); 149 189 expect(stats.terminal.rows).toBe(24); 150 190 expect(stats.terminal.cols).toBe(80); 191 + }, 15000); 192 + 193 + it("surfaces a missing cwd explicitly instead of failing silently", async () => { 194 + const dir = makeSessionDir(); 195 + const name = uniqueName(); 196 + const missingDir = path.join(testRoot, `missing-${name}`); 197 + 198 + const result = await startDaemonExpectFailure(dir, name, missingDir); 199 + 200 + expect(result.exitCode).toBe(1); 201 + expect(result.stderr).toContain(`Working directory does not exist: ${missingDir}`); 202 + expect(result.stderr).toContain(`Cannot start session "${name}"`); 203 + }, 15000); 204 + 205 + it("surfaces a non-directory cwd explicitly instead of reporting posix_spawnp", async () => { 206 + const dir = makeSessionDir(); 207 + const name = uniqueName(); 208 + const filePath = path.join(testRoot, `file-${name}`); 209 + fs.writeFileSync(filePath, "not a directory"); 210 + 211 + const result = await startDaemonExpectFailure(dir, name, filePath); 212 + 213 + expect(result.exitCode).toBe(1); 214 + expect(result.stderr).toContain(`Working directory is not a directory: ${filePath}`); 215 + expect(result.stderr).not.toContain("posix_spawnp failed"); 216 + }, 15000); 217 + 218 + it("non-interactive CLI commands still work when the caller cwd was deleted", () => { 219 + const dir = makeSessionDir(); 220 + const deletedCwd = fs.mkdtempSync(path.join(testRoot, "deleted-cwd-")); 221 + const script = `cd ${JSON.stringify(deletedCwd)} && rmdir ${JSON.stringify(deletedCwd)} && exec ${JSON.stringify(nodeBin)} ${JSON.stringify(cliPath)} list`; 222 + 223 + const result = spawnSync("sh", ["-lc", script], { 224 + env: { ...process.env, PTY_SESSION_DIR: dir }, 225 + encoding: "utf-8", 226 + timeout: 10000, 227 + }); 228 + 229 + expect(result.status).toBe(0); 230 + expect(result.stderr).not.toContain("uv_cwd"); 151 231 }, 15000); 152 232 });