diff --git a/.gitignore b/.gitignore index 0c05a21..83fb59d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ package.json.bak # Logs *.log +lcov.info # AI tooling (local-only agent state) .agents/ diff --git a/README.md b/README.md index f7203c2..c087570 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ + + # webtty -A web TTY for running CLI/TUI applications in a browser tab, across platforms. +Terminal UI in the browser. Run CLI/TUI applications in a browser tab, across platforms. + +```sh +npx webtty run # start server + open a terminal in the browser +npx webtty ls # list sessions +npx webtty help # show all commands +``` ## Debugging diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..0bc6d12 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,4 @@ + + + >_ + diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000..a091c81 Binary files /dev/null and b/assets/social-preview.png differ diff --git a/docs/adrs/002.cli.start-stop.md b/docs/adrs/002.cli.start-stop.md index 03a0100..bd9cac9 100644 --- a/docs/adrs/002.cli.start-stop.md +++ b/docs/adrs/002.cli.start-stop.md @@ -1,4 +1,4 @@ -# ADR 002: CLI — `webtty start` / `webtty stop` +# ADR 002: CLI — `webtty start` / `webtty stop` / `webtty restart` **SPEC:** [cli](../specs/cli.md) **Status:** Accepted @@ -26,9 +26,15 @@ Add a CLI entry point (`src/cli.ts`) and a `POST /api/server/stop` endpoint to ` - Polls `GET /api/sessions` until it responds (or times out), then prints `webtty started`. This ensures the server is actually ready before the CLI exits. **`webtty stop`**: -- Sends `POST http://localhost:PORT/api/server/stop`. -- If the request succeeds: prints `webtty stopped`. -- If the request fails (connection refused): prints `webtty is not running`. +- Checks `GET /api/sessions` first — if connection refused, prints `webtty is not running` and exits 0. +- Otherwise sends `POST http://localhost:PORT/api/server/stop` and polls until the server is down. +- Prints `webtty stopped` on success, `webtty stop failed` and exits 1 if the server doesn't come down. + +**`webtty restart`**: +- If the server is running, calls the same stop logic (wait for down). +- Then calls the same start logic (spawn detached, poll until ready). +- Prints `webtty restarted`. +- If the server is not running, skips the stop step — restart from cold always works. **`server.ts` change**: Add `POST /api/server/stop` — kills all PTY sessions, closes the WebSocket server, and calls `process.exit(0)`. The server owns its own shutdown on all platforms. @@ -64,9 +70,9 @@ A `webtty.sh` that does `node dist/server.js &`. ## Consequences -**Good**: `npx webtty start` works from any directory. Server runs in background, terminal is free. `npx webtty stop` works identically on Mac, Linux, and Windows — no signals, no PID files, no platform-specific code. Server owns its cleanup. No new runtime dependencies. +**Good**: `npx webtty start` works from any directory. Server runs in background, terminal is free. `npx webtty stop` and `npx webtty restart` work identically on Mac, Linux, and Windows — no signals, no PID files, no platform-specific code. Server owns its cleanup. `stopServer()` is extracted into `http.ts` so `stop` and `restart` share the same wait-for-down logic. No new runtime dependencies. -**Bad**: If the server is hung, `webtty stop` fails silently — acceptable for this slice; hard-kill fallback deferred. Port is hardcoded at 2346 — will become configurable when the config file slice lands. +**Bad**: If the server is hung, `webtty stop` fails — acceptable for this slice; hard-kill fallback deferred. Port is hardcoded at 2346 — will become configurable when the config file slice lands. ## Implementation Notes diff --git a/docs/adrs/007.webtty.session-client.md b/docs/adrs/007.webtty.session-client.md index 74ef5f9..5e76e65 100644 --- a/docs/adrs/007.webtty.session-client.md +++ b/docs/adrs/007.webtty.session-client.md @@ -39,7 +39,15 @@ When the PTY exits (e.g. user types `exit`), the server: 1. Deletes the session from the registry immediately 2. Closes all connected WebSockets with code `4001` -The client's existing `4001` handler (introduced in ADR 005 for deleted sessions) writes "Session removed." and calls `window.close()` after 2s. No delay is added before closing — the user explicitly typed `exit`, so immediate closure is the right behaviour. All open tabs for that session close together. +The client's existing `4001` handler (introduced in ADR 005 for deleted sessions) writes "Session removed." and calls `window.close()` after 500ms. No delay is added before closing — the user explicitly typed `exit`, so immediate closure is the right behaviour. All open tabs for that session close together. + +Server stop (`webtty stop` or SIGINT) sends close code `1001` instead. The client writes "Server stopped." and also calls `window.close()` after 500ms. + +**`window.close()` browser restriction**: browsers only permit `window.close()` on tabs that were opened via `window.open()` or duplicated from such a tab. Tabs opened by the OS `open`/`xdg-open` command or by the user typing a URL directly are treated as unowned — `window.close()` is silently ignored with a console warning. In practice this means: +- Tabs opened by `webtty run` and tabs duplicated from them → close automatically ✅ +- Tabs opened by manually navigating to `localhost:2346` → show "Server stopped." / "Session removed." but remain open ⚠️ + +There is no JS workaround for this restriction. It is browser-enforced by design. **Multi-tab: fanout to all connected clients** @@ -102,4 +110,5 @@ Only the first/oldest connected tab can send input; additional tabs are viewers. ## Related Decisions - [ADR 005 — UI Session Support](005.client.session-support.md): Established session persistence across WS disconnects and the `4001` close code +- [ADR 010 — Client UX polish](010.client.ux-polish.md): Redesigned the welcome banner and unified status message styling diff --git a/docs/adrs/008.webtty.config.md b/docs/adrs/008.webtty.config.md new file mode 100644 index 0000000..da87b7d --- /dev/null +++ b/docs/adrs/008.webtty.config.md @@ -0,0 +1,71 @@ +# ADR 008: webtty — Config File + +**SPEC:** [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-03-23 + +--- + +## Context + +All webtty settings are currently hardcoded or read from environment variables only. Users have no persistent way to change port, shell, terminal appearance, or theme without modifying source or wrapping the binary in a shell script. ADR 002 explicitly deferred this: "Port is hardcoded at 2346 — will become configurable when the config file slice lands." + +Three concerns drive the design: + +1. **Where is the file?** It must follow platform conventions and be easy to find. +2. **What format?** Must be human-editable; comments are essential for a config file users edit directly. +3. **What is the load/merge strategy?** Adding new keys in future versions must not break existing configs. + +## Decision + +**File location**: `~/.config/webtty/config.json` — follows the XDG Base Directory convention used by most modern CLI tools (opencode, lazygit, starship, etc.). Created automatically on first run with only `port` and `host` set. + +**Format**: Plain JSON. The file is written by `saveConfig` using `JSON.stringify` and read with `JSON.parse`. Comments are not supported — the schema table in the [config spec](../specs/config.md) serves as documentation. This removes the `strip-json-comments` dependency and keeps the parser a zero-config standard library call. + +**Load strategy**: Merge partial config over defaults — unknown keys are ignored, missing keys fall back to built-in defaults. This means adding new config keys in future versions is non-breaking: an old `config.jsonc` with only `port` still works correctly with a binary that added `theme`. + +**Env overrides**: `PORT` overrides `config.port` at runtime. Applied after file load, never written back to the file. + +**Terminal config delivery**: Server-side config values that affect the browser terminal (cols, rows, fontSize, fontFamily, cursorBlink, scrollback lines, theme) are injected into the HTML template returned by `GET /s/:id`. The client reads them from the rendered page — no separate API endpoint needed. + +**`scrollback`**: A single value in bytes (`256 * 1024` default). Controls both the server-side PTY replay ring buffer (`session.ts`) and the client-side terminal line buffer (derived as `Math.ceil(scrollback / 80)` — assumes ~80 bytes/line average). One knob, one mental model. + +## Considered Options + +**Option A: `.webtty/config.json` in home directory** + +- **Pros**: Simple, familiar to Node.js users. +- **Cons**: Clutters `~` with a dot-directory. XDG convention (`~/.config/`) is now the standard for CLI tools. + +**Option B: TOML format** + +- **Pros**: More readable for nested structures (theme colors). +- **Cons**: Requires a TOML parser dependency (~50 KB). JSON is sufficient and consistent with the existing JSON surface (REST API, `package.json`). + +**Option C: Separate env var for every setting** + +- **Pros**: No new file format. +- **Cons**: Env vars are session-scoped, not persistent. Unwieldy for nested settings like theme colors. Users expect a config file for terminal emulators. + +**Option D: Bun-native JSONC only** + +- **Pros**: Zero bytes added to bundle. +- **Cons**: Breaks Node.js compatibility. webtty explicitly supports both runtimes. + +**Option E: JSONC via `strip-json-comments`** + +- **Pros**: Users can annotate their config with comments. +- **Cons**: Adds a dependency for a cosmetic feature. The schema table in the spec is a better reference than inline comments in a generated file. Dropped in favour of plain JSON. + +## Consequences + +- First run creates `~/.config/webtty/config.json` with `port` and `host` set — users have a ready-to-edit file immediately +- `port`, `host`, `shell`, `term`, `scrollback`, `cols`, `rows`, `fontSize`, `fontFamily`, `cursorBlink`, and `theme` are all user-configurable +- Adding new config keys in future versions is non-breaking +- No additional production dependencies — `JSON.parse` is sufficient +- Terminal appearance config is injected server-side into the HTML — no client-side config fetch needed + +## Related Decisions + +- [ADR 002 — CLI start/stop](002.cli.start-stop.md): explicitly deferred config file; port hardcoded at 2346 +- [ADR 007 — Session client](007.webtty.session-client.md): terminal defaults (cols, rows, theme) previously hardcoded in `client.ts` diff --git a/docs/adrs/009.webtty.config-hot-reload.md b/docs/adrs/009.webtty.config-hot-reload.md new file mode 100644 index 0000000..992c4be --- /dev/null +++ b/docs/adrs/009.webtty.config-hot-reload.md @@ -0,0 +1,51 @@ +# ADR 009: webtty — Config Hot Reload + +**SPEC:** [config](../specs/config.md) +**Status:** Accepted +**Date:** 2026-03-23 + +--- + +## Context + +Config is currently loaded once at server startup and held in memory for the lifetime of the process. Users who edit `~/.config/webtty/config.json` must restart the server to see their changes — even for purely cosmetic settings like font size or theme colors. + +A full hot-reload (e.g. `fs.watch` triggering a live update mid-session) is impractical: the terminal is already initialized in the browser with the old values, and pushing new font/theme settings to a live xterm instance requires non-trivial client-side logic. However, a tab reload already reconstructs the terminal from scratch — the browser fetches `GET /s/:id`, the server renders fresh HTML with config injected, and the client re-initializes xterm. This is a natural reload boundary. + +## Decision + +**Re-read `config.json` on every `GET /s/:id` request** instead of using the startup-cached value. No file watcher, no WebSocket push, no client changes. + +- `loadConfig()` is called inside the `GET /s/:id` handler, not at module load time. +- `port` and `host` remain read once at startup — they are bound to the OS socket and cannot change without a restart. +- `shell` and `term` are re-read when a new PTY is spawned (first WebSocket connection to a session that has no running shell). An already-running session is unaffected. +- All appearance settings (`cols`, `rows`, `fontSize`, `fontFamily`, `cursorBlink`, `scrollback`, `theme`) take effect immediately on the next tab reload. + +## Considered Options + +**Option A: `fs.watch` + WebSocket push to live terminal** + +- **Pros**: Truly live — no reload needed. +- **Cons**: Requires client-side logic to apply new theme/font to a running xterm instance. xterm's `Terminal.options` setter works for some properties but not all (e.g. `fontFamily` requires a full re-render). Significant complexity for marginal benefit — users editing config will naturally reload anyway. + +**Option B: Re-read on every request (chosen)** + +- **Pros**: Zero client changes. Zero file watcher infrastructure. Works with the existing reload boundary. The extra `fs.readFileSync` per page load is negligible (config file is ~3 KB, loaded once per tab open). +- **Cons**: Config changes don't apply to a live tab without a reload — acceptable given the use case. + +**Option C: Keep startup-only load, add `POST /api/config/reload` endpoint** + +- **Pros**: Explicit reload, avoids per-request I/O. +- **Cons**: Requires the user to manually hit an endpoint or use a CLI command. Tab reload is already the natural trigger; adding an extra step is worse UX. + +## Consequences + +- Editing `~/.config/webtty/config.json` and reloading the browser tab picks up all appearance changes immediately — no server restart needed. +- `shell`, `term`, `colorTerm`, and `scrollback` are re-read when a new PTY is spawned — no server restart needed for shell changes either. +- `port` / `host` changes still require a server restart (expected; the socket is already bound). +- `loadConfig()` adds one `fs.readFileSync` + JSON parse per tab load and per new PTY spawn — negligible cost. +- `createWebSocketServer` no longer accepts a `config` parameter — it calls `loadConfig()` itself at PTY spawn time. `index.ts` retains a startup-cached config only for `port`/`host` (server bind). + +## Related Decisions + +- [ADR 008 — Config file](008.webtty.config.md): established config file location, format, and load strategy diff --git a/docs/adrs/010.client.ux-polish.md b/docs/adrs/010.client.ux-polish.md new file mode 100644 index 0000000..8089a94 --- /dev/null +++ b/docs/adrs/010.client.ux-polish.md @@ -0,0 +1,103 @@ +# ADR 010: webtty — Client UX polish (banner, status messages) + +**SPEC:** [client](../specs/client.md) +**Status:** Accepted +**Date:** 2026-03-24 + +--- + +## Context + +The original welcome banner was ported verbatim from the `ghostty-web` demo — a colorful multi-line box with tutorial-style copy ("You have a real shell session with full PTY support. Try: ls, cd, top, vim..."). The disconnect messages used ad-hoc inline ANSI codes with inconsistent colors (red for session removal, yellow for server stop). + +Three problems: + +1. **Banner feels like a demo artifact** — the content is aimed at first-time ghostty-web users, not webtty users. It does not identify the product or give actionable guidance. +2. **No visual identity** — nothing in the UI surfaces the `webtty` name or how to get help. +3. **Inconsistent status messages** — each WS event had its own inline color and wording with no shared style. + +## Decision + +### Welcome banner + +A structured box with two content lines separated by a blank line: + +``` +╔══════════════════════════════════════════════════════╗ +║ ║ +║ [ webtty ] Terminal UI in the browser ║ +║ ║ +║ Run `bunx webtty help` for more information. ║ +║ ║ +╚══════════════════════════════════════════════════════╝ +``` + +**Line 1 — identity + slogan:** +- `[` `]` — dim (ANSI `\x1b[2m`) so they frame without competing +- `webtty` — bold yellow (`\x1b[1;33m`), the only high-contrast element in the banner +- slogan `Terminal UI in the browser` — dim, recedes behind the name + +**Line 2 — help:** +- `Run` and `for more information.` — dim plain +- `` `bunx webtty help` `` — foreground italic (`\x1b[3m`), the actionable part + +**Border:** bright cyan (`\x1b[1;36m`) double-line box. + +**Package runner detection:** `process.execPath.includes('bun')` at server startup — shows `bunx` under Bun, `npx` under Node. + +The banner is shown only on first connect (when `session.pty` is `null`) and written into `session.scrollback` so it appears on replay. Unchanged from ADR 007. + +### Status messages + +All WS lifecycle events that write to the terminal share a single helper: + +``` +[ webtty ] +``` + +- `[ webtty ]` — dim brackets, bold yellow name (same identity as the banner) +- Message text — dim italic (`\x1b[2m\x1b[3m`) + +| Close code | Message | Action | +|------------|---------|--------| +| `4001` | `Session removed.` | `window.close()` after 500ms | +| `1001` | `Server stopped.` | `window.close()` after 500ms | +| other | `Connection lost. Reconnecting in 2s...` | reconnect after 2s | +| `onerror` | `WebSocket error.` | — | + +### `onopen` / `onerror` / `console` policy + +- `ws.onopen` — no terminal message, no `console.log`. The shell prompt appearing is self-evident. +- `ws.onerror` — `term.write(msg(...))` only. No `console.error`. +- `ws.onclose` — `term.write(msg(...))` only. No `console.log`. + +No user-facing event writes to both `term.write` and `console` — terminal output is the single source of truth for the user. + +## Considered Options + +**Option A: No banner (like SSH / macOS Terminal)** + +Rejected — webtty is a web app with a CLI. First-time users need to know where to get help. A minimal banner with the app name and a single help hint is enough. + +**Option B: Keep the ghostty-web demo banner** + +Rejected — tutorial copy ("Try: ls, cd, top, vim") is condescending for users who know what a terminal is. The product name and help command are more useful. + +**Option C: Session ID in the banner** + +Rejected — the session ID is already in the browser tab title (`webtty — `). Repeating it in the terminal adds noise. + +**Option D: Per-event colors for status messages (red for errors, yellow for warnings)** + +Rejected — color-coded severity for these three events adds visual noise with little signal gain. The `[ webtty ]` prefix already distinguishes these messages from shell output. Consistent dim italic is quieter and more readable. + +## Consequences + +- The banner surfaces the product name and a single actionable help command — no tutorial noise. +- All status messages share the same `[ webtty ]` identity pattern — consistent with the banner. +- The package runner (`bunx` vs `npx`) is detected at server startup — correct for the user's environment without configuration. +- `onerror` events now surface in the terminal, previously they were silent to the user (console only). + +## Related Decisions + +- [ADR 007 — Session client](007.webtty.session-client.md): established banner placement (first connect only), scrollback inclusion, and WS close code semantics diff --git a/docs/adrs/011.cli.default-and-help.md b/docs/adrs/011.cli.default-and-help.md new file mode 100644 index 0000000..d29e6b4 --- /dev/null +++ b/docs/adrs/011.cli.default-and-help.md @@ -0,0 +1,78 @@ +# ADR 011: CLI — `webtty` (no-arg entry point) and `webtty help` + +**SPEC:** [cli](../specs/cli.md) +**Status:** Proposed +**Date:** 2026-03-24 + +--- + +## Context + +Two CLI entry points are missing that users will naturally reach for: + +1. **`webtty`** (no arguments) — the most obvious invocation. Currently falls through to Commander's default "display help" behaviour, which is surprising: a user running `npx webtty` for the first time expects something to happen, not a help page. + +2. **`webtty help`** — the banner in every terminal session tells users to run `` `bunx webtty help` `` for more information. That command currently fails with "unknown command". The banner promises something that doesn't exist. + +## Decision + +### `webtty` (no args) + +Equivalent to `webtty run main`: + +1. Start the server if not already running (same logic as `webtty run`) +2. Create or reuse the `main` session +3. Open the session URL in the default browser + +This is the "just works" entry point — the one you'd put in a README quickstart: + +```sh +npx webtty +``` + +**Why `main` and not a generated ID?** `main` is already the default session created by `GET /`. It is a stable, predictable name. Users who open a second terminal can run `webtty run` with an explicit ID without interfering with `main`. + +**`NODE_ENV=test` guard:** same as `openBrowser` — suppressed in test environments to avoid spawning real browser tabs during `bun test`. + +### `webtty help` + +An explicit alias for `--help` — prints the same Commander-generated help output: + +``` +Usage: webtty [options] [command] + +Web TTY — run terminal sessions in a browser tab +... +``` + +This makes the banner's call-to-action (`Run \`bunx webtty help\` for more information.`) functional. It does not replace `--help`; both work. + +## Considered Options + +**Option A: `webtty` opens a new session with a generated ID (not `main`)** + +- **Pros**: Each invocation is a fresh session, no risk of reusing a stale session. +- **Cons**: Multiple invocations accumulate zombie sessions. `main` is predictable and idiomatic — consistent with the server's own default redirect behaviour (`GET /` → `main`). + +**Option B: `webtty` only starts the server, no browser open** + +- **Pros**: Safer, no side effects beyond the server process. +- **Cons**: Requires a second command to actually open a terminal. Defeats the purpose of a frictionless entry point. + +**Option C: `webtty help` renders a rich custom help page** + +- **Pros**: More control over formatting. +- **Cons**: Maintenance burden — Commander already generates good help output and keeps it in sync with registered commands automatically. + +## Consequences + +- `npx webtty` / `bunx webtty` becomes the canonical quickstart — one command to go from zero to a browser terminal. +- The banner's `Run \`bunx webtty help\`` call-to-action becomes functional. +- `webtty run main` remains the explicit equivalent — no behaviour change for existing users. +- The no-arg path reuses all existing `run` logic — minimal new code. + +## Related Decisions + +- [ADR 002 — CLI start/stop](002.cli.start-stop.md): established `startServer` and `isServerRunning` used by the no-arg path +- [ADR 006 — CLI session management](006.cli.session-management.md): established `webtty run` which the no-arg path delegates to +- [ADR 010 — Client UX polish](010.client.ux-polish.md): the banner that references `webtty help` diff --git a/docs/specs/cli.md b/docs/specs/cli.md index 38497b1..6fd8854 100644 --- a/docs/specs/cli.md +++ b/docs/specs/cli.md @@ -27,7 +27,9 @@ The CLI communicates with the server exclusively over HTTP to localhost — no U | `webtty run [id]` | Start server if not running; create session (auto-generates ID if omitted) or reuse if ID exists; open session URL in the default browser | | `webtty rm ` | `DELETE /api/sessions/:id` — kill session and its PTY | | `webtty rename ` | `PATCH /api/sessions/:id` — rename a session; session URL updates to reflect new id | -| `webtty restart` | Stop + start | ⬜ not yet implemented | +| `webtty restart` | Stop + start | +| `webtty` | No-arg entry point — start server if not running, open `main` session in browser | ⬜ | +| `webtty help` | Alias for `--help` — print all commands | ⬜ | ## Features @@ -35,4 +37,6 @@ The CLI communicates with the server exclusively over HTTP to localhost — no U |---------|-------------|-----|-------| | Server lifecycle | `webtty start` / `stop` — fork, detect, and terminate the server over HTTP | [ADR 002](../adrs/002.cli.start-stop.md) | ✅ | | Session management | `webtty run` / `ls` / `rm` / `rename` — create, list, remove, and rename sessions via the REST API | [ADR 006](../adrs/006.cli.session-management.md) | ✅ | -| Server restart | `webtty restart` — stop then start; `POST /api/server/restart` on server side | — | ⬜ | +| Server restart | `webtty restart` — stop then start | [ADR 002](../adrs/002.cli.start-stop.md) | ✅ | +| No-arg entry point | `webtty` — start server + open `main` session in browser | [ADR 011](../adrs/011.cli.default-and-help.md) | ⬜ | +| Help command | `webtty help` — alias for `--help` | [ADR 011](../adrs/011.cli.default-and-help.md) | ⬜ | diff --git a/docs/specs/client.md b/docs/specs/client.md index f3a2ae0..4ae5aaf 100644 --- a/docs/specs/client.md +++ b/docs/specs/client.md @@ -1,7 +1,7 @@ # SPEC: Client **Author:** jesse23 -**Last Updated:** 2026-03-22 +**Last Updated:** 2026-03-24 --- @@ -15,11 +15,71 @@ The client has no build step in the initial slices — plain HTML + `