From 72ea0805ab2a59f9123943a714ae20cf7cc461d5 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 20 Jun 2026 08:45:29 -0400 Subject: [PATCH 01/11] feat: add execute mode endpoint for non-interactive CLI command execution --- docs/adrs/{026.server.pipe.md => 026.server.headless-execute.md} | 0 docs/specs/{server.pipe.md => headless-execute.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename docs/adrs/{026.server.pipe.md => 026.server.headless-execute.md} (100%) rename docs/specs/{server.pipe.md => headless-execute.md} (100%) diff --git a/docs/adrs/026.server.pipe.md b/docs/adrs/026.server.headless-execute.md similarity index 100% rename from docs/adrs/026.server.pipe.md rename to docs/adrs/026.server.headless-execute.md diff --git a/docs/specs/server.pipe.md b/docs/specs/headless-execute.md similarity index 100% rename from docs/specs/server.pipe.md rename to docs/specs/headless-execute.md From 9809314deab357a54a4f277c061b95aaaf464e69 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 20 Jun 2026 08:45:33 -0400 Subject: [PATCH 02/11] feat: enhance error handling for execute endpoint with structured responses --- docs/adrs/026.server.headless-execute.md | 2 +- docs/adrs/027.server.execute-resilience.md | 212 +++++++++++++++++++++ docs/specs/headless-execute.md | 5 +- src/server/index.ts | 15 +- src/server/routes.test.ts | 35 ++++ src/server/routes.ts | 27 ++- 6 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 docs/adrs/027.server.execute-resilience.md diff --git a/docs/adrs/026.server.headless-execute.md b/docs/adrs/026.server.headless-execute.md index 7be6db3..7032145 100644 --- a/docs/adrs/026.server.headless-execute.md +++ b/docs/adrs/026.server.headless-execute.md @@ -1,6 +1,6 @@ # ADR 026: Server — Execute Mode -**SPEC:** [server.pipe](../specs/server.pipe.md) +**SPEC:** [headless-execute](../specs/headless-execute.md) **Status:** Accepted **Date:** 2026-06-11 diff --git a/docs/adrs/027.server.execute-resilience.md b/docs/adrs/027.server.execute-resilience.md new file mode 100644 index 0000000..dd8692a --- /dev/null +++ b/docs/adrs/027.server.execute-resilience.md @@ -0,0 +1,212 @@ +# ADR 027: Server — Execute Resilience & Structured Error Protocol + +**Status**: Accepted +**Date**: 2026-06-20 +**Spec**: [Server — Headless Execute](../specs/headless-execute.md) + +--- + +## 1. Decision Record + + + +### Context + +The `POST /s/:id/execute` endpoint introduced in ADR 026 spawns a child process and streams its output as NDJSON. Two gaps make the current implementation unsafe for production agent workflows: + +**Gap 1 — Server crash on spawn failure.** If the requested command is not found (`ENOENT`) or cannot be executed (`EACCES`), Node.js emits an `error` event on the child process. The handler in ADR 026 has no `child.on('error', …)` listener, so this becomes an unhandled EventEmitter error — which Node.js converts to an uncaught exception and terminates the process. One bad tool call from any agent kills the server for every other user. + +**Gap 2 — Untyped errors in the NDJSON stream.** When the command exits non-zero or spawn itself fails, the caller currently receives only raw stderr text and a numeric exit code. An agent cannot distinguish "the command ran and produced a meaningful error" from "the command never started because it doesn't exist". Both look like `exit: 1` with text in stderr. Without a typed signal, the agent must parse free-form error messages — fragile and brittle across OS and shell versions. + +Additionally, `handleRequest` is an `async` function called inside `http.createServer` with no `.catch()`. A rejected promise from any route handler silently becomes an unhandled rejection, which in Node.js ≥ 15 terminates the process by default. + +### Decision + +Two decisions are made together because they form a single coherent contract: the server stays alive, and the agent always gets a machine-readable error signal. + +**Decision 1 — Process-level resilience.** Register `uncaughtException` and `unhandledRejection` handlers in `src/server/index.ts` that log the error and do not exit. Wrap the `handleRequest` call in `.catch()` to ensure no promise rejection escapes. These are last-resort guards — not a substitute for proper per-handler error handling. + +**Decision 2 — Spawn-level resilience in the execute handler.** In `POST /s/:id/execute`: move `res.writeHead(200, …)` to after the spawn attempt so that synchronous spawn failures can return HTTP 500 before headers are committed. Add `child.on('error', …)` to handle asynchronous spawn errors (ENOENT, EACCES). Use a `done` flag to prevent `res.end()` from being called by both the `error` and `close` handlers. + +**Decision 3 — Structured error on the exit NDJSON line.** When spawn fails (not when the command simply exits non-zero), the final `{ exit }` line carries two additional fields: `error` (human-readable message) and `code` (the Node.js error code string, e.g. `ENOENT`). A non-zero exit from the command itself produces only `{ exit: N }` — no `error` or `code` fields. This one-line distinction gives agents a stable predicate to branch on without parsing stderr text. + +### Considered Options + +#### Option A — Process-level catch-all only + +Register `uncaughtException` / `unhandledRejection` at process level and rely on stderr text for error diagnosis. No changes to the NDJSON stream shape. + +**Pros:** Minimal diff. Server stays alive. +**Cons:** Agents still cannot distinguish spawn failure from command failure without text parsing. The catch-all is a backstop, not a contract. + +#### Option B — Per-handler guard + structured exit line (chosen) + +Fix the execute handler directly (`child.on('error')`, `done` flag, `writeHead` moved), and extend the exit NDJSON line with `error` and `code` fields on spawn failure. Add process-level guards as a second safety net. + +**Pros:** Agent gets a stable, typed signal. Server never crashes. No text parsing required. Backwards-compatible — the `{ exit }` field is unchanged; new fields are additive. +**Cons:** Callers must handle the new fields explicitly to benefit from them. Adds one branch in the execute handler. + +#### Option C — Separate `{ type: "error" }` NDJSON line + +Emit a distinct NDJSON line `{ type: "error", code: "ENOENT", message: "…" }` before the `{ exit: 1 }` line. + +**Pros:** Separates the error signal from the exit signal. Clearer for stream parsers that process line-by-line. +**Cons:** Parsers must now handle a third event type. Existing consumers that only watch for `exit` would ignore the error line entirely, giving no benefit unless they are updated. Option B adds signal to the existing terminal event, which every consumer already reads. + +### Rationale + +Option B is chosen because it adds machine-readable error context to the one line every consumer already waits for — the exit line. Agents do not need code changes to remain correct (the `exit` field is unchanged); they opt in to the richer signal by also checking `code`. The process-level guards are a necessary complement, not a substitute: a catch-all that swallows errors without telling the caller is still a failure mode for the agent. + +### Consequences + +**Positive** +- webtty never terminates due to a bad tool call from an agent. +- Agents can branch on `code === 'ENOENT'` to surface actionable guidance ("install the CLI") instead of forwarding raw stderr text. +- Backwards-compatible: callers that ignore `error`/`code` on the exit line continue to work unchanged. + +**Negative** +- Agents must be updated to consume `code` from the exit line to get the benefit. The current agent (cliIntegrationAgentChatService) reads only `exit` and `data` — it gains resilience but not structured branching until updated. + +**Risks** +- Node.js error codes (ENOENT, EACCES, etc.) are OS-level and stable, but not exhaustive. An unknown code should be treated as a generic spawn failure. Agents should not switch exhaustively on `code`. + +--- + +## 2. Functional Specification + + + +### Scenarios + +**Scenario 1 — Command not found (ENOENT)** + +1. Agent POSTs `{ cmd: "tc", args: ["search", "*"] }` to `/s/main/execute`. +2. `tc` is not on `PATH`. +3. Server spawns the process; Node.js emits `error` on the child with code `ENOENT`. +4. Handler writes `{ stream: "stderr", data: "spawn ENOENT: …\n" }` to the response. +5. Handler writes `{ exit: 1, error: "spawn ENOENT: …", code: "ENOENT" }` and ends. +6. webtty continues serving other requests. Server does not exit. +7. Agent reads `exit: 1, code: "ENOENT"` and surfaces: *"tc is not installed — run: bunx @awc/cli@next …"* + +**Scenario 2 — Command exits non-zero** + +1. Agent POSTs `{ cmd: "tc", args: ["search", "bad-query"] }`. +2. `tc` runs, returns exit code 2. +3. Handler writes stderr chunks as they arrive, then `{ exit: 2 }`. +4. No `error` or `code` fields on the exit line — this is a command-level failure, not a spawn failure. +5. Agent reads `exit: 2`, inspects stderr, and decides whether to retry or surface the error. + +**Scenario 3 — Unhandled exception in a route handler** + +1. A route handler throws an unexpected exception. +2. The `.catch()` wrapper in `index.ts` catches the rejected promise. +3. Server writes `500 Internal Server Error` and continues. +4. `uncaughtException` guard (last resort) logs and does not call `process.exit`. + +### Non-Goals + +- This ADR does not define agent-side error handling strategy — that is up to each consumer. +- This ADR does not add structured error fields to the `publish` endpoint or WebSocket channel. +- This ADR does not address authentication or rate-limiting on the execute endpoint. + +### Data Contracts + +**Execute NDJSON stream — extended exit line** + +``` +{ "stream": "stdout", "data": "…" } // zero or more stdout chunks +{ "stream": "stderr", "data": "…" } // zero or more stderr chunks +{ "exit": 0 } // command exited successfully +{ "exit": 1 } // command exited with error (no spawn issue) +{ "exit": 1, "error": "…", "code": "ENOENT" } // spawn itself failed +``` + +`error` and `code` are present **only** when spawn fails (the child process never started). A non-zero exit from a running command produces `{ exit: N }` with no additional fields. + +`code` is the Node.js `SystemError.code` string (e.g. `ENOENT`, `EACCES`, `EPERM`). Callers should treat unrecognised codes as generic spawn failures. + +--- + +## 3. Implementation Specification + + + +### Implementation Detail + +**`src/server/index.ts` — process-level guards** + +Add before `httpServer.listen`: + +```ts +process.on('uncaughtException', (err) => { + console.error('[webtty] uncaughtException:', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('[webtty] unhandledRejection:', reason); +}); +``` + +Wrap the `handleRequest` call in the server callback: + +```ts +const httpServer = http.createServer((req, res) => { + handleRequest(req, res, distPath, wasmPath, clientDistPath, shutdown).catch((err) => { + console.error('[webtty] unhandled route error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); +}); +``` + +**`src/server/routes.ts` — execute handler hardening** + +Replace the spawn block (currently lines 371–397) with: + +```ts +let child: ReturnType; +try { + child = spawn(cmd, args as string[], { env: process.env }); +} catch (err) { + res.writeHead(500); + res.end(`spawn error: ${String(err)}`); + return; +} +res.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' }); +let done = false; +child.on('error', (err) => { + if (done) return; + done = true; + const msg = err.message; + const code = (err as NodeJS.ErrnoException).code; + res.write(JSON.stringify({ stream: 'stderr', data: `${msg}\n` }) + '\n'); + res.write(JSON.stringify({ exit: 1, error: msg, ...(code ? { code } : {}) }) + '\n'); + res.end(); +}); +if (typeof stdin === 'string') child.stdin?.write(stdin); +child.stdin?.end(); +child.stdout?.on('data', (chunk: Buffer) => { + res.write(JSON.stringify({ stream: 'stdout', data: chunk.toString() }) + '\n'); +}); +child.stderr?.on('data', (chunk: Buffer) => { + res.write(JSON.stringify({ stream: 'stderr', data: chunk.toString() }) + '\n'); +}); +child.on('close', (code: number | null) => { + if (done) return; + done = true; + res.write(JSON.stringify({ exit: code ?? 1 }) + '\n'); + res.end(); +}); +req.on('close', () => { if (!done) child.kill(); }); +``` + +Key invariants: +- `writeHead(200)` only fires after `spawn` succeeds — synchronous spawn errors return `500` with no NDJSON. +- `done` flag ensures exactly one `res.end()` call regardless of whether `error` or `close` fires first (both can fire on ENOENT). +- `error` and `code` appear on the exit line only when the child's `error` event fires — never on a normal non-zero exit. + +### Related Decisions + +- [ADR 026 — Server Execute Mode](026.server.headless-execute.md) — the endpoint this ADR hardens +- [ADR 025 — Session Channel](025.server.channel.md) — publish/events channel on the same session diff --git a/docs/specs/headless-execute.md b/docs/specs/headless-execute.md index 1414077..be2031f 100644 --- a/docs/specs/headless-execute.md +++ b/docs/specs/headless-execute.md @@ -63,7 +63,7 @@ Any browser-based tool (extension, localhost web app) that needs to call a local 6. On process exit: `{"exit":0}` — then response ends 7. If the request is cancelled mid-stream, the child process is killed -For API reference and design rationale see [ADR 026](../adrs/026.server.pipe.md). +For API reference and design rationale see [ADR 026](../adrs/026.server.headless-execute.md). --- @@ -71,4 +71,5 @@ For API reference and design rationale see [ADR 026](../adrs/026.server.pipe.md) | Feature | Description | ADR | Done? | |---------|-------------|-----|-------| -| Execute endpoint | `POST /s/:id/execute` — spawn a CLI command alongside a running session, stream stdout/stderr as ndjson, close with exit code | [ADR 026](../adrs/026.server.pipe.md) | ☐ | +| Execute endpoint | `POST /s/:id/execute` — spawn a CLI command alongside a running session, stream stdout/stderr as ndjson, close with exit code | [ADR 026](../adrs/026.server.headless-execute.md) | ✓ | +| Execute resilience & structured errors | Process-level crash guards; `child.on('error')` handler; exit line carries `error`+`code` on spawn failure so agents can branch without parsing stderr text | [ADR 027](../adrs/027.server.execute-resilience.md) | ✓ | diff --git a/src/server/index.ts b/src/server/index.ts index d3ab79a..45e7061 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -36,8 +36,21 @@ function shutdown() { }); } +process.on('uncaughtException', (err) => { + console.error('[webtty] uncaughtException:', err); +}); +process.on('unhandledRejection', (reason) => { + console.error('[webtty] unhandledRejection:', reason); +}); + const httpServer = http.createServer((req, res) => { - handleRequest(req, res, distPath, wasmPath, clientDistPath, shutdown); + handleRequest(req, res, distPath, wasmPath, clientDistPath, shutdown).catch((err) => { + console.error('[webtty] unhandled route error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + }); }); const wss = createWebSocketServer(httpServer); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index a98ccfa..46749e5 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -331,6 +331,41 @@ describe('server — routes', () => { expect(exitLine?.['exit']).toBe(0); }); + test('POST /s/:id/execute streams stderr and exit:1 for unknown command', async () => { + await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'execute-enoent' }), + }); + const wsUrl = baseUrl.replace(/^http/, 'ws'); + const ws = new WebSocket(`${wsUrl}/ws/execute-enoent/pty?cols=80&rows=24`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error('WS error')); + setTimeout(() => reject(new Error('WS timeout')), 5000); + }); + const res = await fetch(`${baseUrl}/s/execute-enoent/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd: '__no_such_command__', args: [] }), + }); + ws.close(); + expect(res.status).toBe(200); + const text = await res.text(); + const lines = text + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l) as Record); + const stderrLine = lines.find((l) => l['stream'] === 'stderr'); + expect(stderrLine).toBeDefined(); + const exitLine = lines.find((l) => 'exit' in l); + expect(exitLine).toBeDefined(); + expect(exitLine?.['exit']).toBe(1); + expect(exitLine?.['code']).toBe('ENOENT'); + expect(typeof exitLine?.['error']).toBe('string'); + }); + test('POST /api/server/stop returns 200 and stops server', async () => { const res = await fetch(`${baseUrl}/api/server/stop`, { method: 'POST' }); expect(res.status).toBe(200); diff --git a/src/server/routes.ts b/src/server/routes.ts index 60c6b1b..6a57782 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -368,12 +368,28 @@ export async function handleRequest( res.end('invalid body'); return; } + let child: ReturnType; + try { + child = spawn(cmd, args as string[], { env: process.env }); + } catch (err) { + res.writeHead(500); + res.end(`spawn error: ${String(err)}`); + return; + } res.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache', }); - const child = spawn(cmd, args as string[], { env: process.env }); - let childExited = false; + let done = false; + child.on('error', (err) => { + if (done) return; + done = true; + const msg = err.message; + const code = (err as NodeJS.ErrnoException).code; + res.write(JSON.stringify({ stream: 'stderr', data: `${msg}\n` }) + '\n'); + res.write(JSON.stringify({ exit: 1, error: msg, ...(code ? { code } : {}) }) + '\n'); + res.end(); + }); if (typeof stdin === 'string') { child.stdin?.write(stdin); } @@ -385,14 +401,13 @@ export async function handleRequest( res.write(JSON.stringify({ stream: 'stderr', data: chunk.toString() }) + '\n'); }); child.on('close', (code: number | null) => { - childExited = true; + if (done) return; + done = true; res.write(JSON.stringify({ exit: code ?? 1 }) + '\n'); res.end(); }); req.on('close', () => { - if (!childExited) { - child.kill(); - } + if (!done) child.kill(); }); return; } From afc27df6069dbb09ffc16834f0ace1b74997c8ec Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 20 Jun 2026 11:50:30 -0400 Subject: [PATCH 03/11] feat: add base directory support for PTY sessions in CLI and API --- docs/adrs/028.session.base-dir.md | 319 ++++++++++++++++++++++++++++++ docs/specs/webtty.md | 3 +- package.json | 3 +- src/cli/commands.ts | 5 +- src/cli/index.ts | 14 +- src/pty/bun.ts | 5 +- src/pty/index.ts | 4 +- src/pty/node.ts | 5 +- src/server/routes.ts | 21 +- src/server/session.ts | 8 +- src/server/websocket.ts | 2 +- 11 files changed, 371 insertions(+), 18 deletions(-) create mode 100644 docs/adrs/028.session.base-dir.md diff --git a/docs/adrs/028.session.base-dir.md b/docs/adrs/028.session.base-dir.md new file mode 100644 index 0000000..1b82fea --- /dev/null +++ b/docs/adrs/028.session.base-dir.md @@ -0,0 +1,319 @@ +# ADR 028: Session — Base Directory + +**Status**: Proposed +**Date**: 2026-06-20 +**Spec**: [webtty](../specs/webtty.md) + +--- + +## 1. Decision Record + + + +### Context + +When a PTY session is spawned, the shell starts in the user's home directory (`homedir()`), hardcoded in both `src/pty/node.ts` and `src/pty/bun.ts`. There is no way for a caller — via the CLI or the HTTP API — to specify a different starting directory. + +This matters for project-scoped workflows: opening `webtty` from a project directory should start the shell there, not in `~`. Currently, the user must manually `cd` into the project after the shell opens. + +Two surfaces need to support `baseDir`: + +- **HTTP API** (`POST /api/sessions`): allows programmatic callers and agents to set the directory when creating a session. +- **CLI** (`webtty [go] [id]`): allows developers to pass `--dir ` from the terminal, typically as `.` or an absolute project path. + +`baseDir` applies only when a **new** session is created. If the session already exists, the field is silently ignored — the shell's working directory is already live. + +The current default (`homedir()`) is correct and should be preserved when `baseDir` is not specified. + +### Decision + +**Decision 1 — Store `baseDir` on the `Session` struct.** +Add a `baseDir: string` field to `Session` (default: `homedir()`). It is set once at `createSession` time and never mutated. It is used when the PTY is first spawned (lazy, on first WebSocket connect). + +**Decision 2 — Accept `baseDir` in `POST /api/sessions`.** +Extend the request body to `{ id?: string, baseDir?: string }`. The server validates that `baseDir`, if supplied, is an absolute path that exists on disk (`fs.existsSync`). Invalid values return `400`. The value is stored on the session and returned in the `GET /api/sessions` and `GET /api/sessions/:id` responses. + +**Decision 3 — Pass `baseDir` through the PTY spawn chain.** +`createSession`, `spawnForSession`, and the underlying `spawn` in both `src/pty/node.ts` and `src/pty/bun.ts` all receive a `cwd` parameter. The hardcoded `homedir()` calls in the PTY implementations are removed; callers supply `cwd` explicitly. + +**Decision 4 — Add `--dir ` to the CLI.** +`webtty [go] [id] [--dir ]` parses `--dir` from `rest` in `cmdGo`. The path is resolved to an absolute path relative to `process.cwd()` of the CLI process before it is sent to the API. This allows `webtty --dir .` to work naturally from a project directory. + +### Considered Options + +#### Option A — Resolve relative paths server-side + +Accept relative paths in the API and resolve them relative to the server process's `cwd`. + +**Pros:** Simpler API — callers don't need to know the server's location. +**Cons:** The server's `cwd` is unrelated to the caller's intent. `--dir .` sent as-is would resolve to wherever the server was launched, not where the CLI was invoked. Confusing and error-prone. + +#### Option B — Require absolute paths in the API; resolve on the CLI (chosen) + +Require absolute paths at the API boundary. The CLI resolves relative paths (`path.resolve`) before sending. Other HTTP callers (agents, scripts) must send absolute paths. + +**Pros:** No ambiguity. The server validates a concrete path. The CLI transparently handles the common `--dir .` case. +**Cons:** Programmatic callers must resolve paths themselves. + +#### Option C — Accept `baseDir` on the WebSocket URL instead + +Pass `?cwd=…` on `GET /ws/:id/pty` so the directory is set at PTY-spawn time, not session-creation time. + +**Pros:** No session struct change needed. +**Cons:** The directory would need to match across reconnects. Storing it on the session (Option B) is the natural place — it belongs to the session, not the connection. + +### Rationale + +Option B is chosen because `baseDir` is a session-level concept, not a per-connection concept. Tying it to session creation is the right abstraction: the directory is set once, is part of the session's identity, and is visible in the session listing for diagnostics. Resolving on the CLI keeps the server logic simple and the API contract clear. + +### Consequences + +**Positive** +- `webtty --dir .` opens a terminal rooted in the current project directory. +- Agents creating sessions via `POST /api/sessions` can pin a working directory. +- The session list (`GET /api/sessions`) exposes `baseDir` for observability. +- Default behaviour (`homedir()`) is unchanged for callers that omit `baseDir`. + +**Negative** +- The `spawn` function signatures in both PTY implementations gain a `cwd` parameter — a breaking internal change, but these are not public APIs. +- Agents sending relative paths to the API will get a `400`; they must resolve first. + +**Non-goals** +- `baseDir` cannot be changed after session creation. Reopening the session in a different directory requires creating a new session. +- `POST /s/:id/execute` does not accept a per-request `cwd` override. Commands always run in `session.baseDir`. Callers that need a different directory can wrap the command (`sh -c "cd /other && …"`) or create a new session. + +--- + +## 2. Functional Specification + + + +### Default Behaviour + +When `baseDir` is not specified, it defaults to `homedir()`. This matches the current behaviour exactly. + +### Scenarios + +**Scenario 1 — CLI: open a project directory** + +1. Developer runs `webtty --dir .` from `/Users/alice/projects/myapp`. +2. CLI resolves `.` → `/Users/alice/projects/myapp` and POSTs `{ id: "main", baseDir: "/Users/alice/projects/myapp" }`. +3. Server creates session `main` with `baseDir = "/Users/alice/projects/myapp"`. +4. Browser connects; PTY spawns shell with `cwd = "/Users/alice/projects/myapp"`. +5. Shell prompt shows `~/projects/myapp`. + +**Scenario 2 — Session already exists, `--dir` ignored** + +1. Developer runs `webtty --dir /tmp` but session `main` already exists. +2. CLI checks `GET /api/sessions/main` → 200. Session exists; CLI skips the POST entirely. +3. Browser opens the existing session. `baseDir` from the CLI arg is never sent. + +**Scenario 3 — API: agent creates a scoped session** + +1. Agent POSTs `{ id: "proj-abc", baseDir: "/workspace/proj-abc" }` to `/api/sessions`. +2. Server validates path exists. Returns `201` with `{ id: "proj-abc", baseDir: "/workspace/proj-abc", … }`. +3. PTY spawns in `/workspace/proj-abc` when the WebSocket connects. + +**Scenario 4 — Invalid baseDir** + +1. Caller POSTs `{ baseDir: "relative/path" }`. +2. Server returns `400` with `{ error: "baseDir must be an absolute path" }`. + +1. Caller POSTs `{ baseDir: "/does/not/exist" }`. +2. Server returns `400` with `{ error: "baseDir does not exist: /does/not/exist" }`. + +### Data Contracts + +**`POST /api/sessions` — request body** + +```json +{ "id": "myproject", "baseDir": "/Users/alice/projects/myapp" } +``` + +`baseDir` is optional. If omitted, defaults to `homedir()`. Must be an absolute path that exists on the server's filesystem. + +**`GET /api/sessions` / `GET /api/sessions/:id` — response** + +```json +{ + "id": "myproject", + "createdAt": 1750000000000, + "connected": false, + "pid": null, + "baseDir": "/Users/alice/projects/myapp" +} +``` + +`baseDir` is always present in session responses (defaulting to `homedir()`). + +**CLI flag** + +``` +webtty [go] [id] [--dir ] +``` + +`--dir` accepts absolute or relative paths. Relative paths are resolved against `process.cwd()` of the CLI process before being sent to the API. Ignored if the session already exists. + +--- + +## 3. Implementation Specification + + + +### Files to change + +**`src/server/session.ts`** + +Add `baseDir: string` to the `Session` interface. Update `createSession` to accept an optional second argument: + +```ts +import { homedir } from 'node:os'; + +export interface Session { + id: string; + createdAt: number; + baseDir: string; // ← new + pty: PtyProcess | null; + clients: Set; + subscribers: Set; + scrollback: string; +} + +export function createSession(id: string, baseDir = homedir()): Session { + const session: Session = { + id, + createdAt: Date.now(), + baseDir, // ← new + pty: null, + clients: new Set(), + subscribers: new Set(), + scrollback: '', + }; + sessionRegistry.set(id, session); + return session; +} +``` + +Add `baseDir` to `sessionToJson`: + +```ts +export function sessionToJson(s: Session) { + return { + id: s.id, + createdAt: s.createdAt, + connected: s.clients.size > 0, + pid: s.pty?.pid ?? null, + baseDir: s.baseDir, // ← new + }; +} +``` + +**`src/server/routes.ts` — `POST /api/sessions`** + +Extend the body type and add validation before `createSession`: + +```ts +let body: { id?: string; baseDir?: string }; +// … existing readJson/catch block … + +const baseDir = body.baseDir ?? homedir(); +if (!path.isAbsolute(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'baseDir must be an absolute path' })); + return; +} +if (!fs.existsSync(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `baseDir does not exist: ${baseDir}` })); + return; +} +const session = createSession(id, baseDir); +``` + +Add `import fs from 'node:fs'` at the top of the file. `path` is already imported. + +**`src/pty/index.ts` — `spawnForSession`** + +Add `cwd` parameter, pass through to `_spawn`: + +```ts +export function spawnForSession( + cols: number, + rows: number, + shell: string, + term: string, + colorTerm: string, + cwd: string, // ← new +) { + return _spawn(shell, cols, rows, term, colorTerm, cwd); +} +``` + +**`src/pty/node.ts` and `src/pty/bun.ts`** + +Add `cwd: string` as the last parameter to `spawn`. Replace `cwd: homedir()` with `cwd` in the spawn options. Remove the `homedir` import if it is no longer used. + +```ts +export function spawn( + shell: string, + cols: number, + rows: number, + term: string, + colorTerm: string, + cwd: string, // ← new (was homedir() inline) +): PtyProcess { +``` + +**`src/server/websocket.ts`** + +Pass `session.baseDir` to `spawnForSession`: + +```ts +session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir); +``` + +**`src/cli/index.ts`** + +Extract `--dir` from the raw argv *before* splitting `cmd`/`rest`, so that `webtty --dir .` is not mistakenly treated as an unknown command named `--dir`: + +```ts +const allArgs = process.argv.slice(2); +const dirFlagIdx = allArgs.indexOf('--dir'); +let cliBaseDir: string | undefined; +if (dirFlagIdx !== -1) { + cliBaseDir = path.resolve(allArgs[dirFlagIdx + 1] ?? '.'); + allArgs.splice(dirFlagIdx, 2); +} +const [cmd, ...rest] = allArgs; + +if (!cmd) { + await cmdGo('main', cliBaseDir); +} else if (GO_ALIASES.has(cmd)) { + await cmdGo(rest[0] ?? 'main', cliBaseDir); +} +``` + +Add `import path from 'node:path'` at the top of `index.ts`. + +**`src/cli/commands.ts` — `cmdGo`** + +Accept `baseDir` directly (already resolved to absolute by `index.ts`): + +```ts +export async function cmdGo(id = 'main', baseDir?: string): Promise { + // … + // In the POST /api/sessions body (only when creating a new session): + body: JSON.stringify({ id, ...(baseDir ? { baseDir } : {}) }), +``` + +### Key invariants + +- `baseDir` is validated at request time (API) or resolved at call time (CLI) — the PTY spawn never sees an invalid or relative path. +- Hardcoded `homedir()` is removed from both PTY implementations; the default lives only in `createSession`. +- Callers that omit `baseDir` observe no behaviour change. + +### Related Decisions + +- [ADR 004 — Session API](004.webtty.session-api.md) — original session creation contract +- [ADR 006 — CLI Session Management](006.cli.session-management.md) — `cmdGo` and session lifecycle +- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — execute endpoint (baseDir is a non-goal there) diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index d3059fa..b6d6b1e 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -60,7 +60,7 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Method | Path | Description | |--------|------|-------------| | `GET` | `/api/sessions` | List all sessions; connection refused = server not running | -| `POST` | `/api/sessions` | Create session; body `{ id? }`; auto-generates ID if omitted; `409` if ID exists; validates ID rules | +| `POST` | `/api/sessions` | Create session; body `{ id?, baseDir? }`; auto-generates ID if omitted; `409` if ID exists; validates ID rules; `baseDir` must be an absolute path that exists on disk (default: `homedir()`) | | `GET` | `/api/sessions/:id` | Get single session; `404` if absent | | `PATCH` | `/api/sessions/:id` | Rename session; body `{ id }`; `409` if new ID exists; `404` if session absent; validates ID rules | | `DELETE` | `/api/sessions/:id` | Kill PTY, close connected WebSocket, remove session; `204` with `X-Sessions-Remaining: ` header, or `404` | @@ -81,3 +81,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ✅ | +| Session base directory | `POST /api/sessions` accepts `baseDir` (absolute path); PTY shell spawns there instead of `homedir()`; CLI `webtty [go] [id] --dir ` resolves and forwards it; ignored when attaching to an existing session | [ADR 028](../adrs/028.session.base-dir.md) | ☐ | diff --git a/package.json b/package.json index d4f24ad..68f0b19 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "build": "bun run scripts/build.ts", "server": "bun run dist/server/index.js", "server:node": "node dist/server/index.js", - "webtty": "bun -- dist/cli/index.js", + "webtty:prod": "bun -- dist/cli/index.js", + "webtty": "bun run src/cli/index.ts", "prepack": "bun scripts/clean-pkg-scripts.ts strip", "postpack": "bun scripts/clean-pkg-scripts.ts restore" }, diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 96d5abe..3961b59 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -20,8 +20,9 @@ export function toBrowserHost(host: string): string { * Opens (or creates) session `id`, starts the server if needed, and opens the URL in the browser. * * @param id - The session ID to open (default: `'main'`). + * @param baseDir - Working directory for the PTY shell (optional). */ -export async function cmdGo(id = 'main'): Promise { +export async function cmdGo(id = 'main', baseDir?: string): Promise { if (!(await isServerRunning())) { await startServer(); } @@ -34,7 +35,7 @@ export async function cmdGo(id = 'main'): Promise { const res = await fetch(`${getBaseUrl()}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), + body: JSON.stringify({ id, ...(baseDir ? { baseDir } : {}) }), }); if (!res.ok) { const body = (await res.json()) as { error?: string }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 56002ef..83f259a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { cmdConfig, cmdGo, @@ -38,12 +39,19 @@ function printHelp(): void { ); } -const [, , cmd, ...rest] = process.argv; +const allArgs = process.argv.slice(2); +const dirFlagIdx = allArgs.indexOf('--dir'); +let cliBaseDir: string | undefined; +if (dirFlagIdx !== -1) { + cliBaseDir = path.resolve(allArgs[dirFlagIdx + 1] ?? '.'); + allArgs.splice(dirFlagIdx, 2); +} +const [cmd, ...rest] = allArgs; if (!cmd) { - await cmdGo(); + await cmdGo('main', cliBaseDir); } else if (GO_ALIASES.has(cmd)) { - await cmdGo(rest[0]); + await cmdGo(rest[0] ?? 'main', cliBaseDir); } else { switch (cmd) { case 'ls': diff --git a/src/pty/bun.ts b/src/pty/bun.ts index cf5ced7..2d25c32 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -1,4 +1,3 @@ -import { homedir } from 'node:os'; import type { PtyProcess } from './types'; /** @@ -9,6 +8,7 @@ import type { PtyProcess } from './types'; * @param rows - Terminal height in rows. * @param term - `$TERM` environment variable (e.g., `xterm-256color`). * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ export function spawn( @@ -17,6 +17,7 @@ export function spawn( rows: number, term: string, colorTerm: string, + cwd: string, ): PtyProcess { let onDataCb: ((data: string) => void) | undefined; let onExitCb: ((e: { exitCode: number }) => void) | undefined; @@ -29,7 +30,7 @@ export function spawn( onDataCb?.(Buffer.from(data).toString('utf8')); }, }, - cwd: homedir(), + cwd, env: { ...process.env, TERM: term, COLORTERM: colorTerm }, }); diff --git a/src/pty/index.ts b/src/pty/index.ts index fc1ae5f..44dbe72 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -18,6 +18,7 @@ export const spawn = _spawn; * @param shell - Shell executable path (e.g., `/bin/bash`). * @param term - `$TERM` environment variable (e.g., `xterm-256color`). * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ export function spawnForSession( @@ -26,6 +27,7 @@ export function spawnForSession( shell: string, term: string, colorTerm: string, + cwd: string, ) { - return _spawn(shell, cols, rows, term, colorTerm); + return _spawn(shell, cols, rows, term, colorTerm, cwd); } diff --git a/src/pty/node.ts b/src/pty/node.ts index 3639e8d..da1c165 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -1,4 +1,3 @@ -import { homedir } from 'node:os'; import nodePty from '@lydell/node-pty'; import type { PtyProcess } from './types'; @@ -10,6 +9,7 @@ import type { PtyProcess } from './types'; * @param rows - Terminal height in rows. * @param term - `$TERM` environment variable (e.g., `xterm-256color`). * @param colorTerm - `$COLORTERM` environment variable (e.g., `truecolor`). + * @param cwd - Working directory for the shell. * @returns A {@link PtyProcess} handle for reading/writing and managing the PTY. */ export function spawn( @@ -18,6 +18,7 @@ export function spawn( rows: number, term: string, colorTerm: string, + cwd: string, ): PtyProcess { // On Windows, cmd.exe may have an AutoRun registry key that launches shell // enhancers (e.g. clink). These take over the ConPTY pipe and cause @@ -28,7 +29,7 @@ export function spawn( name: term, cols, rows, - cwd: homedir(), + cwd, env: { ...process.env, TERM: term, COLORTERM: colorTerm }, }); diff --git a/src/server/routes.ts b/src/server/routes.ts index 6a57782..e82ad96 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,5 +1,7 @@ import { spawn } from 'node:child_process'; +import fs from 'node:fs'; import type http from 'node:http'; +import { homedir } from 'node:os'; import path from 'node:path'; import { loadConfig } from '../config'; import { @@ -131,9 +133,9 @@ export async function handleRequest( } if (req.method === 'POST') { - let body: { id?: string }; + let body: { id?: string; baseDir?: string }; try { - body = (await readJson(req)) as { id?: string }; + body = (await readJson(req)) as { id?: string; baseDir?: string }; } catch (err) { const status = (err as { status?: number }).status === 413 ? 413 : 400; res.writeHead(status); @@ -152,7 +154,18 @@ export async function handleRequest( res.end(JSON.stringify({ error: `session already exists: ${id}` })); return; } - const session = createSession(id); + const baseDir = body.baseDir ?? homedir(); + if (!path.isAbsolute(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'baseDir must be an absolute path' })); + return; + } + if (!fs.existsSync(baseDir)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `baseDir does not exist: ${baseDir}` })); + return; + } + const session = createSession(id, baseDir); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(sessionToJson(session))); return; @@ -370,7 +383,7 @@ export async function handleRequest( } let child: ReturnType; try { - child = spawn(cmd, args as string[], { env: process.env }); + child = spawn(cmd, args as string[], { env: process.env, cwd: session.baseDir }); } catch (err) { res.writeHead(500); res.end(`spawn error: ${String(err)}`); diff --git a/src/server/session.ts b/src/server/session.ts index 919d4cc..a1524f7 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -1,3 +1,4 @@ +import { homedir } from 'node:os'; import type { WebSocket as WS } from 'ws'; import type { PtyProcess } from '../pty'; @@ -7,6 +8,8 @@ export interface Session { id: string; /** Unix timestamp (ms) when the session was created. */ createdAt: number; + /** Working directory for the PTY shell. */ + baseDir: string; /** The underlying PTY process, or `null` if no shell has been spawned yet. */ pty: PtyProcess | null; /** All currently connected WebSocket clients for this session. */ @@ -55,12 +58,14 @@ export function generateId(): string { * Creates a new session, registers it in {@link sessionRegistry}, and returns it. * * @param id - The session ID. + * @param baseDir - Working directory for the PTY shell (default: `homedir()`). * @returns The newly created {@link Session}. */ -export function createSession(id: string): Session { +export function createSession(id: string, baseDir = homedir()): Session { const session: Session = { id, createdAt: Date.now(), + baseDir, pty: null, clients: new Set(), subscribers: new Set(), @@ -80,6 +85,7 @@ export function sessionToJson(s: Session) { return { id: s.id, createdAt: s.createdAt, + baseDir: s.baseDir, connected: s.clients.size > 0, pid: s.pty?.pid ?? null, }; diff --git a/src/server/websocket.ts b/src/server/websocket.ts index b1888b3..e5397f7 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -190,7 +190,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer if (!session.pty) { const config = loadConfig(); - session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm); + session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir); session.pty.onData((data: string) => { session.scrollback = (session.scrollback + data).slice(-config.scrollback); From a84f5088d3bf29446a46d7cf022e9e2fe749db1e Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 20 Jun 2026 16:59:13 -0400 Subject: [PATCH 04/11] feat: add `webtty exec` command for headless execution in sessions --- docs/adrs/029.cli.exec.md | 210 ++++++++++++++++++++++++++++++++++++++ docs/specs/cli.md | 1 + 2 files changed, 211 insertions(+) create mode 100644 docs/adrs/029.cli.exec.md diff --git a/docs/adrs/029.cli.exec.md b/docs/adrs/029.cli.exec.md new file mode 100644 index 0000000..5ba63fa --- /dev/null +++ b/docs/adrs/029.cli.exec.md @@ -0,0 +1,210 @@ +# ADR 029: CLI — `exec` command + +**Status**: Proposed +**Date**: 2026-06-20 +**Spec**: [CLI](../specs/cli.md) + +--- + +## 1. Decision Record + +### Context + +The `POST /s/:id/execute` endpoint (ADR 026) lets any HTTP caller run a CLI command headlessly inside a running session. There is no CLI surface for it — developers must use `curl` or write code to drive it. Adding a first-class `webtty exec` command makes the endpoint directly usable from a terminal or shell script, and gives the feature the same discoverability as the rest of the CLI. + +### Decision + +**Decision 1 — Add `webtty exec [args...]` subcommand.** +`id` is always required — there is no default. This mirrors `docker exec ` and avoids ambiguity: without a required id, `webtty exec tc search "*"` would be ambiguous between `tc` as a session id or a command. + +**Decision 2 — Variadic args; no quoting required.** +The shell already splits tokens before Node.js receives `process.argv`. `webtty exec main gh pr list --state open` arrives as `["gh", "pr", "list", "--state", "open"]` with no extra parsing. Quoted args with spaces (`--search "fix login bug"`) also work automatically. The CLI passes `rest[0]` as `cmd` and `rest.slice(1)` as `args` to the execute body. + +**Decision 3 — Server must already be running; exec does not auto-start it.** +`go` auto-starts the server because it's the entry point for a new workflow. `exec` implies a session is already live — if the server is not running or the session does not exist with a running PTY, the command fails immediately with a clear error. Auto-starting would hide bugs and make scripting unpredictable. + +**Decision 4 — Stream stdout/stderr directly; exit with the command's exit code.** +NDJSON lines with `stream: "stdout"` are written to `process.stdout`; `stream: "stderr"` to `process.stderr`. On the `{ exit }` line, the CLI calls `process.exit(code)`. If the exit line carries `error` and `code` fields (spawn failure — ADR 027), the CLI prints the error to stderr before exiting. + +### Considered Options + +#### Option A — Optional id, default to last-used session +`webtty exec [id] [args...]` — id optional, falls back to last session. + +**Pros:** Less typing for the common case. +**Cons:** `webtty exec tc search` is ambiguous — `tc` could be the session or the command. Resolving it requires a server round-trip to check if `tc` is a known session id, making the CLI slower and more complex. + +#### Option B — Required id (chosen) +`webtty exec [args...]` — always explicit. + +**Pros:** Unambiguous. Matches `docker exec`. No server round-trip to resolve args. +**Cons:** One more token to type. Mitigated by the fact that session ids are short and tab-completion is possible. + +### Consequences + +**Positive** +- `POST /s/:id/execute` is now reachable from the terminal with no tooling. +- Consistent with `docker exec` mental model. +- Exit code forwarding makes it composable in shell scripts. + +**Negative** +- `id` is always required — slightly more verbose than `webtty go` which defaults to `main`. + +--- + +## 2. Functional Specification + +### Command syntax + +``` +webtty exec [args...] +``` + +| Token | Required | Description | +|-------|----------|-------------| +| `id` | yes | Session ID — must exist with a running PTY | +| `cmd` | yes | Executable to run | +| `args` | no | Zero or more arguments passed to `cmd` | + +### Behaviour + +1. If the server is not running → print `webtty: server is not running` to stderr, exit 1. +2. If session `` does not exist (404) → print `webtty: session not found: `, exit 1. +3. If session PTY is not running (409) → print `webtty: PTY not running for session: `, exit 1. +4. Stream stdout → `process.stdout`, stderr → `process.stderr`. +5. On exit line: exit with the reported code. +6. On spawn failure (exit line has `error`/`code`): print `webtty: ` to stderr, then exit 1. + +### Examples + +```bash +# Run a search in session "tc" +webtty exec tc gh pr list --state open + +# One-shot claude prompt in session "main" +webtty exec main claude -p "summarise this file" < notes.txt + +# Compose with pipes (shell handles this, not webtty) +webtty exec main cat package.json | jq '.version' +``` + +### Error messages + +| Condition | Message | +|-----------|---------| +| Server not running | `webtty: server is not running` | +| Session not found | `webtty: session not found: ` | +| PTY not running | `webtty: PTY not running for session: ` | +| Spawn failure (ENOENT) | `webtty: spawn tc: No such file or directory` | +| Other fetch error | `webtty: exec failed ()` | + +--- + +## 3. Implementation Specification + +### `src/cli/commands.ts` — add `cmdExec` + +```ts +export async function cmdExec(id: string, cmd: string, args: string[]): Promise { + if (!(await isServerRunning())) { + console.error('webtty: server is not running'); + process.exit(1); + } + + let res: Response; + try { + res = await fetch(`${getBaseUrl()}/s/${encodeURIComponent(id)}/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd, args }), + }); + } catch { + console.error('webtty: server is not running'); + process.exit(1); + } + + if (res.status === 404) { + console.error(`webtty: session not found: ${id}`); + process.exit(1); + } + if (res.status === 409) { + console.error(`webtty: PTY not running for session: ${id}`); + process.exit(1); + } + if (!res.ok || !res.body) { + console.error(`webtty: exec failed (${res.status})`); + process.exit(1); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop()!; + for (const line of lines) { + if (!line) continue; + const msg = JSON.parse(line) as Record; + if (msg['stream'] === 'stdout') process.stdout.write(String(msg['data'])); + else if (msg['stream'] === 'stderr') process.stderr.write(String(msg['data'])); + else if ('exit' in msg) { + if (msg['error']) console.error(`webtty: ${msg['error']}`); + process.exit((msg['exit'] as number) ?? 1); + } + } + } +} +``` + +### `src/cli/index.ts` — register `exec` + +Add `cmdExec` to the existing import from `./commands`: + +```ts +import { + cmdConfig, + cmdExec, // ← add + cmdGo, + ... +} from './commands'; +``` + +Add to the COMMANDS list in `printHelp` (fits within the existing `col = 18` width): + +```ts +row('exec ', 'Run a command in a session and stream its output'), +``` + +Add the switch case: + +```ts +case 'exec': { + const [id, cmd, ...execArgs] = rest; + if (!id || !cmd) { + console.error('usage: webtty exec [args...]'); + process.exit(1); + } + await cmdExec(id, cmd, execArgs); + break; +} +``` + +Note: the `--dir` flag extracted at the top of `index.ts` is simply unused for `exec` — no change needed there. + +### Key invariants + +- `exec` never auto-starts the server. +- `id`, `cmd`, and `args` are passed as-is to the HTTP body — no shell parsing, no interpolation. +- Exit code is always forwarded; the CLI process exit code matches the spawned command's exit code. +- On spawn failure (`code` field present on exit line), the error is printed before exit so the caller sees a clear message rather than a silent non-zero exit. + +### Related decisions + +- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — the HTTP endpoint this command drives +- [ADR 027 — Execute Resilience](027.server.execute-resilience.md) — structured error on the exit line +- [ADR 028 — Session Base Directory](028.session.base-dir.md) — commands run in `session.baseDir` +- [ADR 006 — CLI Session Management](006.cli.session-management.md) — existing CLI pattern this command follows diff --git a/docs/specs/cli.md b/docs/specs/cli.md index f28bd3a..1739eec 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -59,3 +59,4 @@ The command exits when the editor exits. | Help and config | `webtty help` — show all commands; `webtty config` — open config in `$VISUAL`/`$EDITOR`/`vi` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Help formatting | Description first, all-caps headings, aligned params, frequency-ordered commands, annotated usage lines | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Stop on last rm | `webtty rm` auto-stops the server when the last session is removed | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | +| Headless exec | `webtty exec [args...]` — run a command in a session, stream stdout/stderr, forward exit code; server must already be running | [ADR 029](../adrs/029.cli.exec.md) | ☐ | From a6d350588dd8e661ccc3fc5a8b1f2ae6ec2e6404 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 20 Jun 2026 17:12:15 -0400 Subject: [PATCH 05/11] feat: add `exec` command to run a command in a session and stream its output --- src/cli/commands.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 11 +++++++++ 2 files changed, 65 insertions(+) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 3961b59..8c96f48 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -217,6 +217,60 @@ export function bytesToDisplay(buf: Buffer): string { .join(' '); } +export async function cmdExec(id: string, cmd: string, args: string[]): Promise { + if (!(await isServerRunning())) { + console.error('webtty: server is not running'); + process.exit(1); + } + + let res: Response; + try { + res = await fetch(`${getBaseUrl()}/s/${encodeURIComponent(id)}/execute`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cmd, args }), + }); + } catch { + console.error('webtty: server is not running'); + process.exit(1); + } + + if (res.status === 404) { + console.error(`webtty: session not found: ${id}`); + process.exit(1); + } + if (res.status === 409) { + console.error(`webtty: PTY not running for session: ${id}`); + process.exit(1); + } + if (!res.ok || !res.body) { + console.error(`webtty: exec failed (${res.status})`); + process.exit(1); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop()!; + for (const line of lines) { + if (!line) continue; + const msg = JSON.parse(line) as Record; + if (msg['stream'] === 'stdout') process.stdout.write(String(msg['data'])); + else if (msg['stream'] === 'stderr') process.stderr.write(String(msg['data'])); + else if ('exit' in msg) { + if (msg['error']) console.error(`webtty: ${msg['error']}`); + process.exit((msg['exit'] as number) ?? 1); + } + } + } +} + export function cmdKey(): void { if (!process.stdin.isTTY) { console.error('webtty key: requires an interactive terminal'); diff --git a/src/cli/index.ts b/src/cli/index.ts index 83f259a..f83e73e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import { cmdConfig, + cmdExec, cmdGo, cmdKey, cmdList, @@ -33,6 +34,7 @@ function printHelp(): void { row('stop', 'Stop the webtty server'), row('start', 'Start the webtty server'), row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), + row('exec ', 'Run a command in a session and stream its output'), row('key', 'Capture a key combo and print its chars value for keyboardBindings'), row('help', 'Show this help message'), ].join('\n'), @@ -76,6 +78,15 @@ if (!cmd) { case 'config': cmdConfig(); break; + case 'exec': { + const [id, cmd, ...execArgs] = rest; + if (!id || !cmd) { + console.error('usage: webtty exec [args...]'); + process.exit(1); + } + await cmdExec(id, cmd, execArgs); + break; + } case 'key': cmdKey(); break; From 970552f1388e6e8d6f31dd1bdb7f0d1c653a1758 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 20 Jun 2026 17:55:16 -0400 Subject: [PATCH 06/11] feat: add environment variable injection support for PTY shells and execute commands --- docs/adrs/030.config.env-inject.md | 229 +++++++++++++++++++++++++++++ docs/specs/config.md | 1 + src/config.ts | 12 ++ src/pty/bun.ts | 3 +- src/pty/index.test.ts | 4 +- src/pty/index.ts | 3 +- src/pty/node.ts | 3 +- src/server/routes.ts | 3 +- src/server/websocket.ts | 2 +- 9 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 docs/adrs/030.config.env-inject.md diff --git a/docs/adrs/030.config.env-inject.md b/docs/adrs/030.config.env-inject.md new file mode 100644 index 0000000..0620ec4 --- /dev/null +++ b/docs/adrs/030.config.env-inject.md @@ -0,0 +1,229 @@ +# ADR 030: Config — Environment Variable Injection + +**Status**: Proposed +**Date**: 2026-06-20 +**Spec**: [Config](../specs/config.md) + +--- + +## 1. Decision Record + +### Context + +PTY shells and execute commands currently inherit only `process.env` — the environment of the webtty server process. There is no way to inject additional environment variables (e.g. `ANTHROPIC_API_KEY`, project tokens, tool flags) without setting them in the shell profile or passing them at server startup. + +This is inconvenient for agent-oriented workflows where a browser-launched terminal needs credentials that are not in the user's default shell env (e.g. when webtty is started from a GUI launcher rather than a terminal). It is also inconsistent with the established contract in `~/.claude/settings.json`, which uses a flat `env` object to inject vars into every Claude Code session. + +### Decision + +**Decision 1 — Add `env` to `~/.config/webtty/config.json`, same contract as Claude.** +The format is a flat `Record` under the key `"env"`, identical to `~/.claude/settings.json`: + +```json +{ + "env": { + "ANTHROPIC_API_KEY": "sk-ant-…", + "MY_TOKEN": "abc123" + } +} +``` + +Non-string values are silently ignored at load time, consistent with how webtty handles other invalid config fields. + +**Decision 2 — Apply to both PTY shells and execute commands.** +Both surfaces run user commands and need the same credentials. Applying to one but not the other would create a confusing split where `webtty exec` behaves differently from the interactive shell. + +**Decision 3 — Merge order: `process.env` → `config.env` → terminal vars.** +`config.env` overrides inherited process env, but `TERM` and `COLORTERM` (set by webtty for terminal compatibility) always win and cannot be overridden via `config.env`. Merge order: + +``` +{ ...process.env, ...config.env, TERM: term, COLORTERM: colorTerm } +``` + +For execute commands (which do not set `TERM`/`COLORTERM`): + +``` +{ ...process.env, ...config.env } +``` + +**Decision 4 — Thread `env` through the PTY spawn chain as a parameter.** +`spawnForSession` and the underlying `spawn` in `pty/node.ts` and `pty/bun.ts` gain an `env` parameter. This keeps the PTY layer free of config coupling — callers (currently only `websocket.ts`) supply the env from `config.env`. + +### Considered Options + +#### Option A — Read config inside the PTY `spawn` function + +Have `spawn` call `loadConfig()` itself and read `config.env` there. + +**Pros:** No signature change. +**Cons:** Breaks the separation between the PTY layer and the config layer. Makes the PTY functions harder to test in isolation. `websocket.ts` already calls `loadConfig()` before spawning; a second call inside `spawn` is redundant. + +#### Option B — Pass `env` as a parameter (chosen) + +Caller reads `config.env` and passes it to `spawnForSession`. + +**Pros:** Clean separation. `spawn` remains a pure function of its arguments. Consistent with how `cwd` was threaded through in ADR 028. +**Cons:** One more parameter in the spawn chain (already has `cwd` from ADR 028 — same pattern). + +### Consequences + +**Positive** +- Credentials and tool-specific vars can be set once in `config.json` rather than in shell profiles or server startup env. +- Consistent contract with `~/.claude/settings.json` — users who know Claude's pattern immediately understand webtty's. +- Both PTY and execute surfaces behave identically with respect to env. + +**Negative** +- Sensitive values (API keys) are stored in a plain JSON file. Users are responsible for setting appropriate file permissions (`chmod 600 ~/.config/webtty/config.json`). This is the same responsibility as `~/.claude/settings.json`. +- `TERM` and `COLORTERM` cannot be overridden via `config.env` — this is intentional but may surprise users who try. + +--- + +## 2. Functional Specification + +### Config file format + +```json +{ + "env": { + "ANTHROPIC_API_KEY": "sk-ant-…", + "NODE_ENV": "development" + } +} +``` + +`env` is optional. Default is `{}` (empty — no additional vars). Keys and values must both be strings; any entry where either is not a string is silently dropped. + +### Merge behaviour + +| Source | Priority | +|--------|----------| +| `process.env` (server's inherited env) | lowest | +| `config.env` | overrides above | +| `TERM`, `COLORTERM` (PTY only) | always highest — cannot be overridden | + +### Scope + +| Surface | `config.env` applied? | +|---------|----------------------| +| PTY shell spawn | ✓ | +| `POST /s/:id/execute` commands | ✓ | + +### Non-goals + +- Per-session env overrides — `config.env` applies globally to all sessions. Session-level env is a future concern. +- Per-request env in `POST /s/:id/execute` — callers that need per-invocation vars can pass them as CLI flags to the command itself. +- Encrypting values at rest — out of scope; same stance as `~/.claude/settings.json`. + +--- + +## 3. Implementation Specification + +### `src/config.ts` + +Add `env` to the `Config` interface: + +```ts +/** Additional environment variables injected into PTY shells and execute commands. Merges over `process.env`; `TERM` and `COLORTERM` always take precedence for PTY spawns. */ +env: Record; +``` + +Add to `DEFAULT_CONFIG`: + +```ts +env: {}, +``` + +Add parsing in `loadConfig()` after the `keyboardBindings` block: + +```ts +...(p.env && + typeof p.env === 'object' && + !Array.isArray(p.env) && { + env: Object.fromEntries( + Object.entries(p.env as Record).filter( + ([k, v]) => typeof k === 'string' && typeof v === 'string', + ) as [string, string][], + ), + }), +``` + +### `src/pty/node.ts` and `src/pty/bun.ts` + +Add `env: Record` as a new parameter to `spawn` (after `cwd`). Merge it into the spawn env: + +```ts +// node.ts +export function spawn(shell, cols, rows, term, colorTerm, cwd, env): PtyProcess { + const ptyProc = nodePty.spawn(shell, shellArgs, { + name: term, + cols, + rows, + cwd, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, + }); +``` + +```ts +// bun.ts +export function spawn(shell, cols, rows, term, colorTerm, cwd, env): PtyProcess { + const proc = Bun.spawn([shell], { + terminal: { cols, rows, data(...) { ... } }, + cwd, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, + }); +``` + +### `src/pty/index.ts` + +Add `env: Record` to `spawnForSession` and pass through: + +```ts +export function spawnForSession(cols, rows, shell, term, colorTerm, cwd, env: Record) { + return _spawn(shell, cols, rows, term, colorTerm, cwd, env); +} +``` + +### `src/server/websocket.ts` + +Pass `config.env` to `spawnForSession`: + +```ts +const config = loadConfig(); +session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir, config.env); +``` + +### `src/server/routes.ts` — execute handler + +`loadConfig` is already imported at the top of the file. Each route handler is its own scope — add a local call inside the execute handler, just before the spawn: + +```ts +const config = loadConfig(); +child = spawn(cmd, args as string[], { env: { ...process.env, ...config.env }, cwd: session.baseDir }); +``` + +### `src/pty/index.test.ts` — fix pre-existing missing args + +The test file calls `spawnForSession` without `cwd` (omitted when ADR 028 was implemented). After this ADR adds `env`, both params will be absent. Fix both call sites: + +```ts +// before (broken since ADR 028): +spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor') + +// after: +spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor', homedir(), {}) +``` + +Add `import { homedir } from 'node:os'` at the top of the test file. + +### Key invariants + +- `TERM` and `COLORTERM` are always set after `config.env` in PTY spawns — they cannot be overridden. +- For execute commands, no `TERM`/`COLORTERM` override is applied — `config.env` values for those keys would take effect, which is harmless. +- An empty or absent `env` in config is equivalent to `{}` — no behaviour change for users who don't set it. +- Invalid entries (non-string keys or values) are dropped silently at load time, not at spawn time. + +### Related decisions + +- [ADR 008 — Config](008.webtty.config.md) — config file format and load strategy this ADR extends +- [ADR 028 — Session Base Directory](028.session.base-dir.md) — `cwd` threaded through the same spawn chain; `env` follows the same pattern +- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — execute endpoint that also receives injected env diff --git a/docs/specs/config.md b/docs/specs/config.md index 7a43152..d91a17b 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -210,3 +210,4 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | | Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | +| Env injection | `env` — flat `Record` merged over `process.env` and injected into PTY shells and execute commands; same contract as `~/.claude/settings.json` | [ADR 030](../adrs/030.config.env-inject.md) | ☐ | diff --git a/src/config.ts b/src/config.ts index 1464601..6c71dfb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -97,6 +97,8 @@ export interface Config { theme: Theme; /** Custom key-to-sequence bindings. Merged with built-in defaults by `(key, mods)` identity. */ keyboardBindings: KeyboardBinding[]; + /** Additional environment variables injected into PTY shells and execute commands. Merges over `process.env`; `TERM` and `COLORTERM` always take precedence for PTY spawns. */ + env: Record; } /** Returns the webtty config directory: `~/.config/webtty`. */ @@ -164,6 +166,7 @@ export const DEFAULT_CONFIG: Config = { logs: false, theme: DEFAULT_THEME, keyboardBindings: DEFAULT_KEYBOARD_BINDINGS, + env: {}, }; function bindingKey(b: KeyboardBinding): string { @@ -283,6 +286,15 @@ export function loadConfig(): Config { p.keyboardBindings.filter(isValidBinding).map(normalizeBinding), ), }), + ...(p.env && + typeof p.env === 'object' && + !Array.isArray(p.env) && { + env: Object.fromEntries( + Object.entries(p.env as Record).filter( + ([k, v]) => typeof k === 'string' && typeof v === 'string', + ) as [string, string][], + ), + }), }; } diff --git a/src/pty/bun.ts b/src/pty/bun.ts index 2d25c32..d9f644a 100644 --- a/src/pty/bun.ts +++ b/src/pty/bun.ts @@ -18,6 +18,7 @@ export function spawn( term: string, colorTerm: string, cwd: string, + env: Record, ): PtyProcess { let onDataCb: ((data: string) => void) | undefined; let onExitCb: ((e: { exitCode: number }) => void) | undefined; @@ -31,7 +32,7 @@ export function spawn( }, }, cwd, - env: { ...process.env, TERM: term, COLORTERM: colorTerm }, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, }); proc.exited.then((exitCode) => { diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 929cf4c..2264edc 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -28,7 +28,7 @@ const isBunOnWindows = describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { - const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); + const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor', process.cwd(), {}); expect(typeof pty.onData).toBe('function'); expect(typeof pty.onExit).toBe('function'); @@ -41,7 +41,7 @@ describe('spawnForSession', () => { }); test.skipIf(isBunOnWindows)('spawned process can receive data', async () => { - const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor'); + const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor', process.cwd(), {}); const received: string[] = []; pty.onData((data) => received.push(data)); diff --git a/src/pty/index.ts b/src/pty/index.ts index 44dbe72..746583a 100644 --- a/src/pty/index.ts +++ b/src/pty/index.ts @@ -28,6 +28,7 @@ export function spawnForSession( term: string, colorTerm: string, cwd: string, + env: Record, ) { - return _spawn(shell, cols, rows, term, colorTerm, cwd); + return _spawn(shell, cols, rows, term, colorTerm, cwd, env); } diff --git a/src/pty/node.ts b/src/pty/node.ts index da1c165..005af65 100644 --- a/src/pty/node.ts +++ b/src/pty/node.ts @@ -19,6 +19,7 @@ export function spawn( term: string, colorTerm: string, cwd: string, + env: Record, ): PtyProcess { // On Windows, cmd.exe may have an AutoRun registry key that launches shell // enhancers (e.g. clink). These take over the ConPTY pipe and cause @@ -30,7 +31,7 @@ export function spawn( cols, rows, cwd, - env: { ...process.env, TERM: term, COLORTERM: colorTerm }, + env: { ...process.env, ...env, TERM: term, COLORTERM: colorTerm }, }); return { diff --git a/src/server/routes.ts b/src/server/routes.ts index e82ad96..ba7af89 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -383,7 +383,8 @@ export async function handleRequest( } let child: ReturnType; try { - child = spawn(cmd, args as string[], { env: process.env, cwd: session.baseDir }); + const execConfig = loadConfig(); + child = spawn(cmd, args as string[], { env: { ...process.env, ...execConfig.env }, cwd: session.baseDir }); } catch (err) { res.writeHead(500); res.end(`spawn error: ${String(err)}`); diff --git a/src/server/websocket.ts b/src/server/websocket.ts index e5397f7..9a9db75 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -190,7 +190,7 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer if (!session.pty) { const config = loadConfig(); - session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir); + session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir, config.env); session.pty.onData((data: string) => { session.scrollback = (session.scrollback + data).slice(-config.scrollback); From 71c717afed506db8f3bd7f35d7b0a7a98f5a59a1 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sat, 20 Jun 2026 22:26:57 -0400 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20rename=20exec=E2=86=92run=20and?= =?UTF-8?q?=20add=20PTY=20warm-up=20mode=20(ADR=20029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `webtty run ` starts the server, creates the session if needed, and triggers PTY spawn via a transient WebSocket connection — no browser. `webtty run [args...]` is the renamed headless exec mode. Removes 'run' from GO_ALIASES so it no longer aliases `go`. Co-Authored-By: Claude Sonnet 4.6 --- docs/adrs/029.cli.exec.md | 202 +++++++++++++++++++++++++------------- docs/specs/cli.md | 2 +- src/cli/commands.ts | 49 ++++++++- src/cli/index.ts | 16 +-- 4 files changed, 189 insertions(+), 80 deletions(-) diff --git a/docs/adrs/029.cli.exec.md b/docs/adrs/029.cli.exec.md index 5ba63fa..23b28f3 100644 --- a/docs/adrs/029.cli.exec.md +++ b/docs/adrs/029.cli.exec.md @@ -1,4 +1,4 @@ -# ADR 029: CLI — `exec` command +# ADR 029: CLI — `run` command **Status**: Proposed **Date**: 2026-06-20 @@ -10,45 +10,57 @@ ### Context -The `POST /s/:id/execute` endpoint (ADR 026) lets any HTTP caller run a CLI command headlessly inside a running session. There is no CLI surface for it — developers must use `curl` or write code to drive it. Adding a first-class `webtty exec` command makes the endpoint directly usable from a terminal or shell script, and gives the feature the same discoverability as the rest of the CLI. +Two related needs share the same CLI surface: + +1. **Headless command execution.** The `POST /s/:id/execute` endpoint lets any HTTP caller run a CLI command inside a running session. There is no CLI surface for it — developers must use `curl` or write code to drive it. + +2. **Session warm-up without a browser.** `webtty go ` creates a session and opens a browser. In scripted or CI workflows, you often want to start a session and its PTY so that subsequent `run ` calls work — but without a browser window appearing. + +Both needs share a subject (``) and differ only in whether a command follows. Unifying them under `run` keeps the CLI surface small and the mental model simple. ### Decision -**Decision 1 — Add `webtty exec [args...]` subcommand.** -`id` is always required — there is no default. This mirrors `docker exec ` and avoids ambiguity: without a required id, `webtty exec tc search "*"` would be ambiguous between `tc` as a session id or a command. +**Decision 1 — Add `webtty run [cmd [args...]]` subcommand.** +`id` is always required. `cmd` is optional: its presence determines the mode. -**Decision 2 — Variadic args; no quoting required.** -The shell already splits tokens before Node.js receives `process.argv`. `webtty exec main gh pr list --state open` arrives as `["gh", "pr", "list", "--state", "open"]` with no extra parsing. Quoted args with spaces (`--search "fix login bug"`) also work automatically. The CLI passes `rest[0]` as `cmd` and `rest.slice(1)` as `args` to the execute body. +**Decision 2 — `run ` (no command): start PTY without browser.** +Auto-starts the server if not running (same as `go`). Creates the session if it does not exist. Connects to the PTY WebSocket (`/ws/:id/pty?cols=80&rows=24`) to trigger PTY spawn, then disconnects immediately — the PTY keeps running. Prints the session URL to stdout but does not open the browser. If the session already exists the create step is skipped; PTY start is still attempted so repeated calls are idempotent. -**Decision 3 — Server must already be running; exec does not auto-start it.** -`go` auto-starts the server because it's the entry point for a new workflow. `exec` implies a session is already live — if the server is not running or the session does not exist with a running PTY, the command fails immediately with a clear error. Auto-starting would hide bugs and make scripting unpredictable. +**Decision 3 — `run [args...]` (with command): headless execute.** +Server must already be running. PTY must already be running (409 otherwise). POSTs to `/s/:id/execute`, streams stdout/stderr directly, exits with the command's exit code. Does not auto-start server — this mode is for scripting where setup is explicit. -**Decision 4 — Stream stdout/stderr directly; exit with the command's exit code.** -NDJSON lines with `stream: "stdout"` are written to `process.stdout`; `stream: "stderr"` to `process.stderr`. On the `{ exit }` line, the CLI calls `process.exit(code)`. If the exit line carries `error` and `code` fields (spawn failure — ADR 027), the CLI prints the error to stderr before exiting. +**Decision 4 — Variadic args; no quoting required.** +The shell already splits tokens before Node.js receives `process.argv`. `webtty run main gh pr list --state open` arrives as `["gh", "pr", "list", "--state", "open"]` with no extra parsing. Quoted args with spaces work automatically. + +**Decision 5 — Stream stdout/stderr directly; exit with command's exit code.** +NDJSON lines with `stream: "stdout"` are written to `process.stdout`; `stream: "stderr"` to `process.stderr`. On the `{ exit }` line the CLI calls `process.exit(code)`. If the exit line carries `error`/`code` fields (spawn failure — ADR 027), the error is printed to stderr before exiting. ### Considered Options -#### Option A — Optional id, default to last-used session -`webtty exec [id] [args...]` — id optional, falls back to last session. +#### Option A — Separate `exec` and `start` subcommands + +`webtty exec ` for headless execution, `webtty start ` for PTY warm-up. + +**Pros:** Unambiguous, each subcommand has a single responsibility. +**Cons:** Two new surface-area entries for closely related operations. `start` conflicts with the existing `webtty start` (server start). More to type and remember. -**Pros:** Less typing for the common case. -**Cons:** `webtty exec tc search` is ambiguous — `tc` could be the session or the command. Resolving it requires a server round-trip to check if `tc` is a known session id, making the CLI slower and more complex. +#### Option B — Unified `run [cmd...]` (chosen) -#### Option B — Required id (chosen) -`webtty exec [args...]` — always explicit. +One subcommand, two modes based on whether a command follows. -**Pros:** Unambiguous. Matches `docker exec`. No server round-trip to resolve args. -**Cons:** One more token to type. Mitigated by the fact that session ids are short and tab-completion is possible. +**Pros:** Mirrors how `ssh host [cmd]` works — familiar to developers. Single entry in help. Less surface area. +**Cons:** Two behaviours under one name. Mitigated by clear documentation and consistent id-first convention. ### Consequences **Positive** -- `POST /s/:id/execute` is now reachable from the terminal with no tooling. -- Consistent with `docker exec` mental model. -- Exit code forwarding makes it composable in shell scripts. +- `webtty run ` warms up a session for scripted use without touching the browser. +- `webtty run ` drives `POST /s/:id/execute` directly from the terminal. +- PTY warm-up is idempotent — safe to call multiple times. +- Mirrors `ssh host [cmd]` — a pattern developers already know. **Negative** -- `id` is always required — slightly more verbose than `webtty go` which defaults to `main`. +- The two modes behave differently on server auto-start (warm-up auto-starts; exec does not). This is intentional but worth documenting. --- @@ -57,55 +69,117 @@ NDJSON lines with `stream: "stdout"` are written to `process.stdout`; `stream: " ### Command syntax ``` -webtty exec [args...] +webtty run # warm up: start PTY, print URL, no browser +webtty run [args...] # headless exec: run command, stream output ``` | Token | Required | Description | |-------|----------|-------------| -| `id` | yes | Session ID — must exist with a running PTY | -| `cmd` | yes | Executable to run | +| `id` | yes | Session ID | +| `cmd` | no | If present: executable to run headlessly | | `args` | no | Zero or more arguments passed to `cmd` | -### Behaviour +### Warm-up mode (`run `, no command) -1. If the server is not running → print `webtty: server is not running` to stderr, exit 1. -2. If session `` does not exist (404) → print `webtty: session not found: `, exit 1. -3. If session PTY is not running (409) → print `webtty: PTY not running for session: `, exit 1. +1. Start server if not running. +2. Create session `` if it does not exist; skip creation if it does. +3. Connect to `ws://127.0.0.1:/ws//pty?cols=80&rows=24`. +4. On WebSocket open: PTY is now running. Close the WebSocket and exit. +5. Print session URL to stdout: `http://:/s/`. + +### Exec mode (`run [args...]`) + +1. If server not running → print `webtty: server is not running`, exit 1. +2. If session not found (404) → print `webtty: session not found: `, exit 1. +3. If PTY not running (409) → print `webtty: PTY not running for session: `, exit 1. 4. Stream stdout → `process.stdout`, stderr → `process.stderr`. -5. On exit line: exit with the reported code. -6. On spawn failure (exit line has `error`/`code`): print `webtty: ` to stderr, then exit 1. +5. On exit line: exit with reported code. +6. On spawn failure (`error`/`code` on exit line): print error to stderr, exit 1. ### Examples ```bash -# Run a search in session "tc" -webtty exec tc gh pr list --state open +# Warm up a session (no browser) +webtty run myproject + +# Then run commands in it +webtty run myproject gh pr list --state open +webtty run myproject claude -p "summarise this file" < notes.txt -# One-shot claude prompt in session "main" -webtty exec main claude -p "summarise this file" < notes.txt +# Typical script pattern +webtty run myproject # start PTY +webtty run myproject npm run build # run build +webtty run myproject npm test # run tests -# Compose with pipes (shell handles this, not webtty) -webtty exec main cat package.json | jq '.version' +# Compose with pipes (shell handles splitting) +webtty run main cat package.json | jq '.version' ``` ### Error messages | Condition | Message | |-----------|---------| -| Server not running | `webtty: server is not running` | +| Server not running (exec mode) | `webtty: server is not running` | | Session not found | `webtty: session not found: ` | | PTY not running | `webtty: PTY not running for session: ` | +| WebSocket connection failed (warm-up) | `webtty: failed to start PTY: ` | | Spawn failure (ENOENT) | `webtty: spawn tc: No such file or directory` | -| Other fetch error | `webtty: exec failed ()` | +| Other HTTP error | `webtty: run failed ()` | --- ## 3. Implementation Specification -### `src/cli/commands.ts` — add `cmdExec` +### `src/cli/commands.ts` — add `cmdRun` ```ts -export async function cmdExec(id: string, cmd: string, args: string[]): Promise { +import { WebSocket } from 'ws'; + +export async function cmdRun(id: string, cmd?: string, args: string[] = []): Promise { + if (!cmd) { + // Warm-up mode: start server + session + PTY, no browser + if (!(await isServerRunning())) { + await startServer(); + } + + const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); + if (check.status !== 200) { + const res = await fetch(`${getBaseUrl()}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); + process.exit(1); + } + } + + const wsUrl = `ws://127.0.0.1:${getPort()}/ws/${encodeURIComponent(id)}/pty?cols=80&rows=24`; + await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + let opened = false; + ws.on('open', () => { + opened = true; + ws.close(1000); + resolve(); + }); + ws.on('error', (err) => reject(err)); + ws.on('close', (code) => { + if (!opened) reject(new Error(`failed to start PTY (code ${code})`)); + }); + }).catch((err: Error) => { + console.error(`webtty: failed to start PTY: ${err.message}`); + process.exit(1); + }); + + const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${id}`; + console.log(url); + return; + } + + // Exec mode: headless command execution if (!(await isServerRunning())) { console.error('webtty: server is not running'); process.exit(1); @@ -132,7 +206,7 @@ export async function cmdExec(id: string, cmd: string, args: string[]): Promise< process.exit(1); } if (!res.ok || !res.body) { - console.error(`webtty: exec failed (${res.status})`); + console.error(`webtty: run failed (${res.status})`); process.exit(1); } @@ -160,51 +234,41 @@ export async function cmdExec(id: string, cmd: string, args: string[]): Promise< } ``` -### `src/cli/index.ts` — register `exec` +### `src/cli/index.ts` — register `run` -Add `cmdExec` to the existing import from `./commands`: +Replace `cmdExec` with `cmdRun` in the import from `./commands`. -```ts -import { - cmdConfig, - cmdExec, // ← add - cmdGo, - ... -} from './commands'; -``` - -Add to the COMMANDS list in `printHelp` (fits within the existing `col = 18` width): +Update help text (fits within `col = 18`): ```ts -row('exec ', 'Run a command in a session and stream its output'), +row('run [cmd]', 'Start PTY (no browser) or run a command in a session'), ``` -Add the switch case: +Replace `case 'exec'` with: ```ts -case 'exec': { - const [id, cmd, ...execArgs] = rest; - if (!id || !cmd) { - console.error('usage: webtty exec [args...]'); +case 'run': { + const [id, cmd, ...runArgs] = rest; + if (!id) { + console.error('usage: webtty run [cmd [args...]]'); process.exit(1); } - await cmdExec(id, cmd, execArgs); + await cmdRun(id, cmd, runArgs); break; } ``` -Note: the `--dir` flag extracted at the top of `index.ts` is simply unused for `exec` — no change needed there. - ### Key invariants -- `exec` never auto-starts the server. -- `id`, `cmd`, and `args` are passed as-is to the HTTP body — no shell parsing, no interpolation. -- Exit code is always forwarded; the CLI process exit code matches the spawned command's exit code. -- On spawn failure (`code` field present on exit line), the error is printed before exit so the caller sees a clear message rather than a silent non-zero exit. +- Warm-up mode auto-starts the server; exec mode does not. +- Warm-up is idempotent: if the session or PTY already exists, the relevant steps are skipped silently. +- WebSocket connect/disconnect in warm-up does not kill the PTY — the PTY keeps running when all clients disconnect. +- `id`, `cmd`, and `args` are passed as-is to the HTTP body in exec mode — no shell parsing. +- Exit code is always forwarded in exec mode. ### Related decisions -- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — the HTTP endpoint this command drives +- [ADR 026 — Server Headless Execute](026.server.headless-execute.md) — HTTP endpoint driven by exec mode - [ADR 027 — Execute Resilience](027.server.execute-resilience.md) — structured error on the exit line - [ADR 028 — Session Base Directory](028.session.base-dir.md) — commands run in `session.baseDir` -- [ADR 006 — CLI Session Management](006.cli.session-management.md) — existing CLI pattern this command follows +- [ADR 006 — CLI Session Management](006.cli.session-management.md) — existing CLI pattern diff --git a/docs/specs/cli.md b/docs/specs/cli.md index 1739eec..a2ac0ba 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -59,4 +59,4 @@ The command exits when the editor exits. | Help and config | `webtty help` — show all commands; `webtty config` — open config in `$VISUAL`/`$EDITOR`/`vi` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Help formatting | Description first, all-caps headings, aligned params, frequency-ordered commands, annotated usage lines | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Stop on last rm | `webtty rm` auto-stops the server when the last session is removed | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | -| Headless exec | `webtty exec [args...]` — run a command in a session, stream stdout/stderr, forward exit code; server must already be running | [ADR 029](../adrs/029.cli.exec.md) | ☐ | +| Run | `webtty run ` — start PTY without browser (auto-starts server + session); `webtty run [args...]` — run command headlessly, stream stdout/stderr, forward exit code | [ADR 029](../adrs/029.cli.exec.md) | ☐ | diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 8c96f48..d744dc6 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,6 +1,7 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { WebSocket } from 'ws'; import { configDir, loadConfig } from '../config'; import { getBaseUrl, getPort, isServerRunning, openBrowser, startServer, stopServer } from './http'; @@ -217,7 +218,51 @@ export function bytesToDisplay(buf: Buffer): string { .join(' '); } -export async function cmdExec(id: string, cmd: string, args: string[]): Promise { +export async function cmdRun(id: string, cmd?: string, args: string[] = []): Promise { + if (!cmd) { + // Warm-up mode: start server + session + PTY, no browser + if (!(await isServerRunning())) { + await startServer(); + } + + const check = await fetch(`${getBaseUrl()}/api/sessions/${encodeURIComponent(id)}`); + if (check.status !== 200) { + const res = await fetch(`${getBaseUrl()}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id }), + }); + if (!res.ok) { + const body = (await res.json()) as { error?: string }; + console.error(`webtty: ${body.error ?? `failed to create session (${res.status})`}`); + process.exit(1); + } + } + + const wsUrl = `ws://127.0.0.1:${getPort()}/ws/${encodeURIComponent(id)}/pty?cols=80&rows=24`; + await new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + let opened = false; + ws.on('open', () => { + opened = true; + ws.close(1000); + resolve(); + }); + ws.on('error', (err) => reject(err)); + ws.on('close', (code) => { + if (!opened) reject(new Error(`failed to start PTY (code ${code})`)); + }); + }).catch((err: Error) => { + console.error(`webtty: failed to start PTY: ${err.message}`); + process.exit(1); + }); + + const url = `http://${toBrowserHost(loadConfig().host)}:${getPort()}/s/${id}`; + console.log(url); + return; + } + + // Exec mode: headless command execution if (!(await isServerRunning())) { console.error('webtty: server is not running'); process.exit(1); @@ -244,7 +289,7 @@ export async function cmdExec(id: string, cmd: string, args: string[]): Promise< process.exit(1); } if (!res.ok || !res.body) { - console.error(`webtty: exec failed (${res.status})`); + console.error(`webtty: run failed (${res.status})`); process.exit(1); } diff --git a/src/cli/index.ts b/src/cli/index.ts index f83e73e..65e3e3a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,17 +1,17 @@ import path from 'node:path'; import { cmdConfig, - cmdExec, cmdGo, cmdKey, cmdList, cmdRemove, cmdRename, + cmdRun, cmdStart, cmdStop, } from './commands'; -const GO_ALIASES = new Set(['go', 'a', 'run', 'attach', 'open']); +const GO_ALIASES = new Set(['go', 'a', 'attach', 'open']); function printHelp(): void { const indent = ' '; @@ -31,10 +31,10 @@ function printHelp(): void { row('ls [id]', 'List all sessions, or filter by id substring'), row('rm ', 'Destroy a session'), row('mv ', 'Rename a session'), + row('run [cmd]', 'Start PTY (no browser) or run a command in a session'), row('stop', 'Stop the webtty server'), row('start', 'Start the webtty server'), row('config', 'Open the config file in $VISUAL, $EDITOR, or a default editor'), - row('exec ', 'Run a command in a session and stream its output'), row('key', 'Capture a key combo and print its chars value for keyboardBindings'), row('help', 'Show this help message'), ].join('\n'), @@ -78,13 +78,13 @@ if (!cmd) { case 'config': cmdConfig(); break; - case 'exec': { - const [id, cmd, ...execArgs] = rest; - if (!id || !cmd) { - console.error('usage: webtty exec [args...]'); + case 'run': { + const [id, runCmd, ...runArgs] = rest; + if (!id) { + console.error('usage: webtty run [cmd [args...]]'); process.exit(1); } - await cmdExec(id, cmd, execArgs); + await cmdRun(id, runCmd, runArgs); break; } case 'key': From f02fde56da85cb1d603d9d4f4ab28a2caa44f998 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 21 Jun 2026 10:05:12 -0400 Subject: [PATCH 08/11] chore: fix biome formatting and lint violations Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.ts | 10 +++++----- src/pty/index.test.ts | 20 ++++++++++++++++++-- src/server/routes.test.ts | 14 +++++++------- src/server/routes.ts | 15 +++++++++------ src/server/websocket.ts | 10 +++++++++- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/cli/commands.ts b/src/cli/commands.ts index d744dc6..ae3830b 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -302,15 +302,15 @@ export async function cmdRun(id: string, cmd?: string, args: string[] = []): Pro if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); - buf = lines.pop()!; + buf = lines.pop() ?? ''; for (const line of lines) { if (!line) continue; const msg = JSON.parse(line) as Record; - if (msg['stream'] === 'stdout') process.stdout.write(String(msg['data'])); - else if (msg['stream'] === 'stderr') process.stderr.write(String(msg['data'])); + if (msg.stream === 'stdout') process.stdout.write(String(msg.data)); + else if (msg.stream === 'stderr') process.stderr.write(String(msg.data)); else if ('exit' in msg) { - if (msg['error']) console.error(`webtty: ${msg['error']}`); - process.exit((msg['exit'] as number) ?? 1); + if (msg.error) console.error(`webtty: ${msg.error}`); + process.exit((msg.exit as number) ?? 1); } } } diff --git a/src/pty/index.test.ts b/src/pty/index.test.ts index 2264edc..2f1c899 100644 --- a/src/pty/index.test.ts +++ b/src/pty/index.test.ts @@ -28,7 +28,15 @@ const isBunOnWindows = describe('spawnForSession', () => { test('returns a PtyProcess with the expected interface', () => { - const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor', process.cwd(), {}); + const pty = spawnForSession( + 80, + 24, + TEST_SHELL, + 'xterm-256color', + 'truecolor', + process.cwd(), + {}, + ); expect(typeof pty.onData).toBe('function'); expect(typeof pty.onExit).toBe('function'); @@ -41,7 +49,15 @@ describe('spawnForSession', () => { }); test.skipIf(isBunOnWindows)('spawned process can receive data', async () => { - const pty = spawnForSession(80, 24, TEST_SHELL, 'xterm-256color', 'truecolor', process.cwd(), {}); + const pty = spawnForSession( + 80, + 24, + TEST_SHELL, + 'xterm-256color', + 'truecolor', + process.cwd(), + {}, + ); const received: string[] = []; pty.onData((data) => received.push(data)); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index 46749e5..b04d1da 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -323,12 +323,12 @@ describe('server — routes', () => { .split('\n') .filter(Boolean) .map((l) => JSON.parse(l) as Record); - const stdoutLine = lines.find((l) => l['stream'] === 'stdout'); + const stdoutLine = lines.find((l) => l.stream === 'stdout'); expect(stdoutLine).toBeDefined(); - expect(String(stdoutLine?.['data'])).toContain('hello-execute'); + expect(String(stdoutLine?.data)).toContain('hello-execute'); const exitLine = lines.find((l) => 'exit' in l); expect(exitLine).toBeDefined(); - expect(exitLine?.['exit']).toBe(0); + expect(exitLine?.exit).toBe(0); }); test('POST /s/:id/execute streams stderr and exit:1 for unknown command', async () => { @@ -357,13 +357,13 @@ describe('server — routes', () => { .split('\n') .filter(Boolean) .map((l) => JSON.parse(l) as Record); - const stderrLine = lines.find((l) => l['stream'] === 'stderr'); + const stderrLine = lines.find((l) => l.stream === 'stderr'); expect(stderrLine).toBeDefined(); const exitLine = lines.find((l) => 'exit' in l); expect(exitLine).toBeDefined(); - expect(exitLine?.['exit']).toBe(1); - expect(exitLine?.['code']).toBe('ENOENT'); - expect(typeof exitLine?.['error']).toBe('string'); + expect(exitLine?.exit).toBe(1); + expect(exitLine?.code).toBe('ENOENT'); + expect(typeof exitLine?.error).toBe('string'); }); test('POST /api/server/stop returns 200 and stops server', async () => { diff --git a/src/server/routes.ts b/src/server/routes.ts index ba7af89..b0bb42a 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -384,7 +384,10 @@ export async function handleRequest( let child: ReturnType; try { const execConfig = loadConfig(); - child = spawn(cmd, args as string[], { env: { ...process.env, ...execConfig.env }, cwd: session.baseDir }); + child = spawn(cmd, args as string[], { + env: { ...process.env, ...execConfig.env }, + cwd: session.baseDir, + }); } catch (err) { res.writeHead(500); res.end(`spawn error: ${String(err)}`); @@ -400,8 +403,8 @@ export async function handleRequest( done = true; const msg = err.message; const code = (err as NodeJS.ErrnoException).code; - res.write(JSON.stringify({ stream: 'stderr', data: `${msg}\n` }) + '\n'); - res.write(JSON.stringify({ exit: 1, error: msg, ...(code ? { code } : {}) }) + '\n'); + res.write(`${JSON.stringify({ stream: 'stderr', data: `${msg}\n` })}\n`); + res.write(`${JSON.stringify({ exit: 1, error: msg, ...(code ? { code } : {}) })}\n`); res.end(); }); if (typeof stdin === 'string') { @@ -409,15 +412,15 @@ export async function handleRequest( } child.stdin?.end(); child.stdout?.on('data', (chunk: Buffer) => { - res.write(JSON.stringify({ stream: 'stdout', data: chunk.toString() }) + '\n'); + res.write(`${JSON.stringify({ stream: 'stdout', data: chunk.toString() })}\n`); }); child.stderr?.on('data', (chunk: Buffer) => { - res.write(JSON.stringify({ stream: 'stderr', data: chunk.toString() }) + '\n'); + res.write(`${JSON.stringify({ stream: 'stderr', data: chunk.toString() })}\n`); }); child.on('close', (code: number | null) => { if (done) return; done = true; - res.write(JSON.stringify({ exit: code ?? 1 }) + '\n'); + res.write(`${JSON.stringify({ exit: code ?? 1 })}\n`); res.end(); }); req.on('close', () => { diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 9a9db75..4ec37ae 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -190,7 +190,15 @@ export function createWebSocketServer(httpServer: http.Server): WebSocketServer if (!session.pty) { const config = loadConfig(); - session.pty = spawnForSession(cols, rows, config.shell, config.term, config.colorTerm, session.baseDir, config.env); + session.pty = spawnForSession( + cols, + rows, + config.shell, + config.term, + config.colorTerm, + session.baseDir, + config.env, + ); session.pty.onData((data: string) => { session.scrollback = (session.scrollback + data).slice(-config.scrollback); From f464c625f550ac34349608c09c146606e472d584 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 21 Jun 2026 10:13:15 -0400 Subject: [PATCH 09/11] test: add unit tests for cmdRun, reach 100% coverage Switch warm-up WebSocket from ws package to Bun's native global WebSocket (browser-style API) so it can be mocked in unit tests. Add 13 new unit tests covering all exec-mode and warm-up-mode paths in cmdRun (lines 221-316 were previously uncovered). Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.test.ts | 259 +++++++++++++++++++++++++++++++++++++++ src/cli/commands.ts | 13 +- 2 files changed, 265 insertions(+), 7 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 4dded4f..ba62b23 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -739,4 +739,263 @@ describe('cli — unit (mocked http)', () => { onceSpy.mockRestore(); log.mockRestore(); }); + + // cmdRun — exec mode + + test('cmdRun with cmd when server not running exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: server is not running'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd when fetch throws exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: server is not running'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd 404 exits with session not found', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 404 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: session not found: sess'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd 409 exits with PTY not running message', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 409 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: PTY not running for session: sess'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd non-ok status exits with run failed', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hi'])).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: run failed (500)'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd streams stdout and exits 0', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const enc = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'hello\n' })}\n`)); + controller.enqueue(enc.encode(`${JSON.stringify({ exit: 0 })}\n`)); + controller.close(); + }, + }); + global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; + const write = spyOn(process.stdout, 'write').mockImplementation(() => true); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit0'); + }); + await expect(cmds.cmdRun('sess', 'echo', ['hello'])).rejects.toThrow('exit0'); + expect(write).toHaveBeenCalledWith('hello\n'); + expect(exit).toHaveBeenCalledWith(0); + isRunning.mockRestore(); + write.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun with cmd streams stderr and exits 1 with error message', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const enc = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + enc.encode(`${JSON.stringify({ stream: 'stderr', data: 'err output\n' })}\n`), + ); + controller.enqueue( + enc.encode(`${JSON.stringify({ exit: 1, error: 'command failed' })}\n`), + ); + controller.close(); + }, + }); + global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; + const writeErr = spyOn(process.stderr, 'write').mockImplementation(() => true); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit1'); + }); + await expect(cmds.cmdRun('sess', 'bad-cmd')).rejects.toThrow('exit1'); + expect(writeErr).toHaveBeenCalledWith('err output\n'); + expect(err).toHaveBeenCalledWith('webtty: command failed'); + expect(exit).toHaveBeenCalledWith(1); + isRunning.mockRestore(); + writeErr.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + // cmdRun — warm-up mode (uses global WebSocket) + + function makeMockWebSocket(behavior: 'open' | 'error' | 'close-without-open') { + return class MockWebSocket { + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((evt: CloseEvent) => void) | null = null; + constructor(_url: string) { + queueMicrotask(() => { + if (behavior === 'open') { + this.onopen?.(); + } else if (behavior === 'error') { + this.onerror?.(); + } else { + this.onclose?.({ code: 1006 } as CloseEvent); + } + }); + } + close(_code?: number) { + queueMicrotask(() => this.onclose?.({ code: 1000 } as CloseEvent)); + } + } as unknown as typeof WebSocket; + } + + test('cmdRun warm-up when server running and session exists prints url', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock( + async () => new Response(null, { status: 200 }), + ) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('open'); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRun('warmup-sess'); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/warmup-sess')); + isRunning.mockRestore(); + log.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when server not running starts it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); + const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); + global.fetch = mock( + async () => new Response(null, { status: 200 }), + ) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('open'); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRun('warmup-sess2'); + expect(start).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/warmup-sess2')); + isRunning.mockRestore(); + start.mockRestore(); + log.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when session not found creates it', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + let callCount = 0; + global.fetch = mock(async () => { + callCount++; + if (callCount === 1) return new Response(null, { status: 404 }); // session check + return new Response(JSON.stringify({ id: 'new-sess' }), { status: 200 }); // create session + }) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('open'); + const log = spyOn(console, 'log').mockImplementation(() => {}); + await cmds.cmdRun('new-sess'); + expect(callCount).toBe(2); + expect(log).toHaveBeenCalledWith(expect.stringContaining('/s/new-sess')); + isRunning.mockRestore(); + log.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when session creation fails exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + let callCount = 0; + global.fetch = mock(async () => { + callCount++; + if (callCount === 1) return new Response(null, { status: 404 }); + return new Response(JSON.stringify({ error: 'bad' }), { status: 500 }); + }) as unknown as typeof fetch; + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('fail-sess')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: bad'); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + + test('cmdRun warm-up when WebSocket errors exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock( + async () => new Response(null, { status: 200 }), + ) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('error'); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('ws-err-sess')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('failed to start PTY')); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + global.WebSocket = origWS; + }); + + test('cmdRun warm-up when WebSocket closes before open exits with error', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + global.fetch = mock( + async () => new Response(null, { status: 200 }), + ) as unknown as typeof fetch; + const origWS = global.WebSocket; + global.WebSocket = makeMockWebSocket('close-without-open'); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('ws-close-sess')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith(expect.stringContaining('failed to start PTY')); + isRunning.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + global.WebSocket = origWS; + }); }); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index ae3830b..679cea2 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,7 +1,6 @@ import * as childProcess from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { WebSocket } from 'ws'; import { configDir, loadConfig } from '../config'; import { getBaseUrl, getPort, isServerRunning, openBrowser, startServer, stopServer } from './http'; @@ -243,15 +242,15 @@ export async function cmdRun(id: string, cmd?: string, args: string[] = []): Pro await new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); let opened = false; - ws.on('open', () => { + ws.onopen = () => { opened = true; ws.close(1000); resolve(); - }); - ws.on('error', (err) => reject(err)); - ws.on('close', (code) => { - if (!opened) reject(new Error(`failed to start PTY (code ${code})`)); - }); + }; + ws.onerror = () => reject(new Error('failed to connect to PTY')); + ws.onclose = (evt) => { + if (!opened) reject(new Error(`failed to start PTY (code ${(evt as CloseEvent).code})`)); + }; }).catch((err: Error) => { console.error(`webtty: failed to start PTY: ${err.message}`); process.exit(1); From a7a0eb134afc50d3bcd03bd7e95023740fa1601c Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 21 Jun 2026 10:22:25 -0400 Subject: [PATCH 10/11] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate baseDir is a directory (not just that it exists) — fixes silent runtime failure when a file path is passed to POST /api/sessions - Treat unexpected stream EOF as failure (exit 1) instead of silent success — fixes scripts misreading a mid-stream crash as success - Mark session baseDir, env injection, and webtty run as done in specs Co-Authored-By: Claude Sonnet 4.6 --- docs/specs/cli.md | 2 +- docs/specs/config.md | 2 +- docs/specs/webtty.md | 2 +- src/cli/commands.test.ts | 24 ++++++++++++++++++++++++ src/cli/commands.ts | 2 ++ src/server/routes.test.ts | 22 ++++++++++++++++++++++ src/server/routes.ts | 5 +++++ 7 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/specs/cli.md b/docs/specs/cli.md index a2ac0ba..f78d333 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -59,4 +59,4 @@ The command exits when the editor exits. | Help and config | `webtty help` — show all commands; `webtty config` — open config in `$VISUAL`/`$EDITOR`/`vi` | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Help formatting | Description first, all-caps headings, aligned params, frequency-ordered commands, annotated usage lines | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | | Stop on last rm | `webtty rm` auto-stops the server when the last session is removed | [ADR 011](../adrs/011.cli.config-and-help.md) | ✅ | -| Run | `webtty run ` — start PTY without browser (auto-starts server + session); `webtty run [args...]` — run command headlessly, stream stdout/stderr, forward exit code | [ADR 029](../adrs/029.cli.exec.md) | ☐ | +| Run | `webtty run ` — start PTY without browser (auto-starts server + session); `webtty run [args...]` — run command headlessly, stream stdout/stderr, forward exit code | [ADR 029](../adrs/029.cli.exec.md) | ✅ | diff --git a/docs/specs/config.md b/docs/specs/config.md index d91a17b..d1ecca6 100644 --- a/docs/specs/config.md +++ b/docs/specs/config.md @@ -210,4 +210,4 @@ All theme keys are optional; omitted keys fall back to the Campbell (Windows Ter | Cursor style | `cursorStyle` sets the default cursor shape; DECSCUSR sequences from apps override at runtime | [ADR 013](../adrs/013.client.cursor-style.md) | ✅ | | Mouse scroll speed | `mouseScrollSpeed` scales SGR events per wheel tick for apps with mouse tracking; default `1` | [ADR 017](../adrs/017.client.mouse-scroll.md) | ✅ | | Keyboard bindings | `keyboardBindings` — configurable key-to-sequence mappings sent to PTY; defaults to `[]` | [ADR 018](../adrs/018.key-bindings.config-support.md), [key-bindings spec](key-bindings.md) | ✅ | -| Env injection | `env` — flat `Record` merged over `process.env` and injected into PTY shells and execute commands; same contract as `~/.claude/settings.json` | [ADR 030](../adrs/030.config.env-inject.md) | ☐ | +| Env injection | `env` — flat `Record` merged over `process.env` and injected into PTY shells and execute commands; same contract as `~/.claude/settings.json` | [ADR 030](../adrs/030.config.env-inject.md) | ✅ | diff --git a/docs/specs/webtty.md b/docs/specs/webtty.md index b6d6b1e..9d5abbc 100644 --- a/docs/specs/webtty.md +++ b/docs/specs/webtty.md @@ -81,4 +81,4 @@ Session IDs appear directly in the URL path (`/s/:id`), so they must be valid UR | Multi-client sessions | Multiple browser tabs can attach to the same session; PTY output broadcast to all; scrollback replayed on reconnect | [ADR 007](../adrs/007.webtty.session-client.md) | ✅ | | Config file | Shell, port, font, theme from `~/.config/webtty/config.json`; hot-reload on tab reload | [ADR 008](../adrs/008.webtty.config.md) | ✅ | | Client integration (CLI → Web) | `POST /s/:id/publish` (one-shot or streaming JSON) + `ws /ws/:id/events` subscribe; channel active only while PTY is running; no extra process or port | [ADR 025](../adrs/025.server.channel.md) | ✅ | -| Session base directory | `POST /api/sessions` accepts `baseDir` (absolute path); PTY shell spawns there instead of `homedir()`; CLI `webtty [go] [id] --dir ` resolves and forwards it; ignored when attaching to an existing session | [ADR 028](../adrs/028.session.base-dir.md) | ☐ | +| Session base directory | `POST /api/sessions` accepts `baseDir` (absolute path); PTY shell spawns there instead of `homedir()`; CLI `webtty [go] [id] --dir ` resolves and forwards it; ignored when attaching to an existing session | [ADR 028](../adrs/028.session.base-dir.md) | ✅ | diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index ba62b23..76e31a6 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -836,6 +836,30 @@ describe('cli — unit (mocked http)', () => { exit.mockRestore(); }); + test('cmdRun with cmd exits 1 when stream ends without exit line', async () => { + const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); + const enc = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'partial\n' })}\n`)); + controller.close(); + }, + }); + global.fetch = mock(async () => new Response(body, { status: 200 })) as unknown as typeof fetch; + const write = spyOn(process.stdout, 'write').mockImplementation(() => true); + const err = spyOn(console, 'error').mockImplementation(() => {}); + const exit = spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + await expect(cmds.cmdRun('sess', 'cmd')).rejects.toThrow('exit'); + expect(err).toHaveBeenCalledWith('webtty: connection closed unexpectedly'); + expect(exit).toHaveBeenCalledWith(1); + isRunning.mockRestore(); + write.mockRestore(); + err.mockRestore(); + exit.mockRestore(); + }); + test('cmdRun with cmd streams stderr and exits 1 with error message', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); const enc = new TextEncoder(); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 679cea2..d2c7adf 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -313,6 +313,8 @@ export async function cmdRun(id: string, cmd?: string, args: string[] = []): Pro } } } + console.error('webtty: connection closed unexpectedly'); + process.exit(1); } export function cmdKey(): void { diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index b04d1da..2e74f65 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -144,6 +144,28 @@ describe('server — routes', () => { expect(res.status).toBe(400); }); + test('POST /api/sessions returns 400 for non-existent baseDir', async () => { + const res = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'basedir-missing', baseDir: '/no/such/path' }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain('does not exist'); + }); + + test('POST /api/sessions returns 400 when baseDir is a file not a directory', async () => { + const res = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'basedir-file', baseDir: import.meta.path }), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain('not a directory'); + }); + test('GET /api/sessions/:id returns session', async () => { const res = await fetch(`${baseUrl}/api/sessions/test-session`); expect(res.status).toBe(200); diff --git a/src/server/routes.ts b/src/server/routes.ts index b0bb42a..b531fa6 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -165,6 +165,11 @@ export async function handleRequest( res.end(JSON.stringify({ error: `baseDir does not exist: ${baseDir}` })); return; } + if (!fs.statSync(baseDir).isDirectory()) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `baseDir is not a directory: ${baseDir}` })); + return; + } const session = createSession(id, baseDir); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(sessionToJson(session))); From 194ce96ed918835ef925bfa9c997bec83bb7d4f0 Mon Sep 17 00:00:00 2001 From: jesse23 Date: Sun, 21 Jun 2026 10:24:57 -0400 Subject: [PATCH 11/11] chore: fix biome formatting in commands.test.ts Co-Authored-By: Claude Sonnet 4.6 --- src/cli/commands.test.ts | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/cli/commands.test.ts b/src/cli/commands.test.ts index 76e31a6..10a84c6 100644 --- a/src/cli/commands.test.ts +++ b/src/cli/commands.test.ts @@ -818,7 +818,9 @@ describe('cli — unit (mocked http)', () => { const enc = new TextEncoder(); const body = new ReadableStream({ start(controller) { - controller.enqueue(enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'hello\n' })}\n`)); + controller.enqueue( + enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'hello\n' })}\n`), + ); controller.enqueue(enc.encode(`${JSON.stringify({ exit: 0 })}\n`)); controller.close(); }, @@ -841,7 +843,9 @@ describe('cli — unit (mocked http)', () => { const enc = new TextEncoder(); const body = new ReadableStream({ start(controller) { - controller.enqueue(enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'partial\n' })}\n`)); + controller.enqueue( + enc.encode(`${JSON.stringify({ stream: 'stdout', data: 'partial\n' })}\n`), + ); controller.close(); }, }); @@ -868,9 +872,7 @@ describe('cli — unit (mocked http)', () => { controller.enqueue( enc.encode(`${JSON.stringify({ stream: 'stderr', data: 'err output\n' })}\n`), ); - controller.enqueue( - enc.encode(`${JSON.stringify({ exit: 1, error: 'command failed' })}\n`), - ); + controller.enqueue(enc.encode(`${JSON.stringify({ exit: 1, error: 'command failed' })}\n`)); controller.close(); }, }); @@ -916,9 +918,7 @@ describe('cli — unit (mocked http)', () => { test('cmdRun warm-up when server running and session exists prints url', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock( - async () => new Response(null, { status: 200 }), - ) as unknown as typeof fetch; + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; const origWS = global.WebSocket; global.WebSocket = makeMockWebSocket('open'); const log = spyOn(console, 'log').mockImplementation(() => {}); @@ -932,9 +932,7 @@ describe('cli — unit (mocked http)', () => { test('cmdRun warm-up when server not running starts it', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(false); const start = spyOn(httpModule, 'startServer').mockResolvedValueOnce(undefined); - global.fetch = mock( - async () => new Response(null, { status: 200 }), - ) as unknown as typeof fetch; + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; const origWS = global.WebSocket; global.WebSocket = makeMockWebSocket('open'); const log = spyOn(console, 'log').mockImplementation(() => {}); @@ -987,9 +985,7 @@ describe('cli — unit (mocked http)', () => { test('cmdRun warm-up when WebSocket errors exits with error', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock( - async () => new Response(null, { status: 200 }), - ) as unknown as typeof fetch; + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; const origWS = global.WebSocket; global.WebSocket = makeMockWebSocket('error'); const err = spyOn(console, 'error').mockImplementation(() => {}); @@ -1006,9 +1002,7 @@ describe('cli — unit (mocked http)', () => { test('cmdRun warm-up when WebSocket closes before open exits with error', async () => { const isRunning = spyOn(httpModule, 'isServerRunning').mockResolvedValueOnce(true); - global.fetch = mock( - async () => new Response(null, { status: 200 }), - ) as unknown as typeof fetch; + global.fetch = mock(async () => new Response(null, { status: 200 })) as unknown as typeof fetch; const origWS = global.WebSocket; global.WebSocket = makeMockWebSocket('close-without-open'); const err = spyOn(console, 'error').mockImplementation(() => {});