From c1fae78c8e856d59638a8e54bd268d50b0983d20 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 2 Aug 2026 14:08:03 -0500 Subject: [PATCH 1/6] Tighten the comment policy to default-no-comment Drop the carve-outs for module/file-header doc blocks and section dividers, and spell out the never-write list so the project policy matches the global one instead of contradicting it. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 56de99a..8ccd05f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,10 +5,12 @@ Bun-specific guidance (use `bun`, `Bun.serve`, `bun test`, etc.) — follow it. ## Comments: explain *why*, not *what* -Comments must add information the code cannot express on its own. A comment that -restates what the adjacent code plainly does is noise: it duplicates the code -(a DRY violation), and it silently rots when the code changes. Delete such -comments; make the code itself readable instead. +Default to writing **no** comment. A comment must earn its place — add one only +when a competent reader of the code would otherwise be genuinely confused, and +no amount of renaming or restructuring fixes it. A comment that restates what +the adjacent code plainly does is noise: it duplicates the code (a DRY +violation), and it silently rots when the code changes. Delete such comments; +make the code itself readable instead. **Remove** — comments that only restate the code: @@ -26,16 +28,32 @@ async kill() { ... } hmr: true, ``` +**Never write:** + +- Restatements of the code (`// increment i`, `// set the name`). +- Section dividers and banners (`// --- lifecycle ---`, `// ===== HELPERS =====`) + or narration inside a function (`// Step 1: parse input`). If a file or + function needs signposting, split it up or rename things. +- Module/file-header doc blocks describing a component's role and design. The + module's name and its exports are the documentation. +- Change narration aimed at the reader of a diff (`// added this to fix the + bug`, `// new`, `// changed from foo to bar`). That belongs in the commit + message. +- Commented-out code. Delete it; version control remembers. +- Redundant docstrings that only echo the signature and parameter names. +- TODO/FIXME notes unless the user asked for them. +- Comments explaining what a well-named identifier already says — improve the + name instead. + **Keep** — comments that carry information the code cannot: - *Why* something is done: rationale, trade-offs, invariants, ordering - constraints, race conditions, gotchas, workarounds for external behavior. + constraints, race conditions, gotchas, workarounds for external behavior + (link the issue/spec/ticket). - The underlying command/API a wrapper drives, when not obvious from the code (e.g. `/** Delete a branch (\`branch -d\`, or \`-D\` with force). */`). - Non-obvious return/parameter conventions - (e.g. "returns `""` when HEAD is detached"). -- Module/file-header docs describing a component's role and design. -- Section dividers (`// --- lifecycle ---`) used to navigate a long file. + (e.g. "returns `""` when HEAD is detached"), including units. Rule of thumb: if deleting the comment loses no information a reader couldn't get from the code in a second, delete it. When a comment feels necessary to explain From 4abd276a8345aaa0055a64acdf8d060027b0ccd7 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 2 Aug 2026 14:25:43 -0500 Subject: [PATCH 2/6] Strip comments that the tightened policy no longer allows Remove module/file-header blocks, section dividers, restatement docstrings, ceremonial @param/@returns templates, and narration across all ten code workspaces. Trim the blocks whose buried rationale was worth keeping down to that rationale. Comments only: the sole non-comment changes are three trailing-comment strips and one `catch { /* ignore */ }` collapsed to `catch {}`. Typecheck clean and 746 tests pass, unchanged from before. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/attach.ts | 19 +----- apps/cli/src/client.ts | 9 --- apps/cli/src/format.ts | 24 +------ apps/cli/src/index.ts | 7 --- apps/cli/src/launch-command.ts | 8 --- apps/cli/src/launch-config.ts | 22 ------- apps/cli/src/render-grid.ts | 21 +------ apps/fagent/src/agent-workspace.ts | 7 +-- apps/fagent/src/client.ts | 9 --- apps/fagent/src/format.ts | 23 ++----- apps/fagent/src/index.ts | 8 --- apps/fagent/src/repo-command.ts | 14 +---- apps/fagent/tests/repo-command.test.ts | 1 - packages/bun-vt/src/cell.ts | 22 +------ packages/bun-vt/src/color.ts | 15 ----- packages/bun-vt/src/index.ts | 17 ----- packages/bun-vt/src/parser.ts | 45 +------------ packages/bun-vt/src/screen.ts | 46 +------------- packages/bun-vt/src/sgr.ts | 15 +---- packages/bun-vt/src/terminal.ts | 18 +----- packages/bun-vt/src/wcwidth.ts | 15 +---- packages/bun-vt/test/acceptance.test.ts | 6 +- packages/bun-vt/test/cursor.test.ts | 4 -- packages/bun-vt/test/modes.test.ts | 5 -- packages/bun-vt/test/parser.test.ts | 4 -- packages/bun-vt/test/scroll.test.ts | 6 -- packages/bun-vt/test/sgr.test.ts | 4 -- packages/bun-vt/test/unicode.test.ts | 8 +-- packages/fleet-bridge/src/api/armory.ts | 8 --- packages/fleet-bridge/src/api/http.ts | 7 --- packages/fleet-bridge/src/api/index.ts | 8 --- packages/fleet-bridge/src/api/repos.ts | 8 --- packages/fleet-bridge/src/api/ships.ts | 5 -- .../fleet-bridge/src/api/system-resources.ts | 6 -- packages/fleet-bridge/src/api/workspaces.ts | 7 --- .../fleet-bridge/src/armory/armory-service.ts | 27 -------- .../fleet-bridge/src/armory/armory-watcher.ts | 14 ----- packages/fleet-bridge/src/config.ts | 15 ----- packages/fleet-bridge/src/fleet-manager.ts | 63 ++----------------- packages/fleet-bridge/src/index.ts | 8 +-- packages/fleet-bridge/src/providers/github.ts | 12 +--- packages/fleet-bridge/src/providers/index.ts | 10 --- .../fleet-bridge/src/providers/provider.ts | 15 ----- packages/fleet-bridge/src/ship-connection.ts | 28 +-------- packages/fleet-bridge/src/store/store.ts | 2 - packages/fleet-bridge/src/types.ts | 12 ---- packages/fleet-bridge/tests/api.test.ts | 11 ---- .../fleet-bridge/tests/armory-push.test.ts | 9 --- .../fleet-bridge/tests/armory-ships.test.ts | 9 --- packages/fleet-bridge/tests/armory.test.ts | 6 -- .../fleet-bridge/tests/fleet-manager.test.ts | 9 --- packages/fleet-bridge/tests/helpers.ts | 15 ----- .../tests/repo-provider-api.test.ts | 6 -- .../tests/ship-connection.test.ts | 7 --- .../fleet-bridge/tests/terminal-proxy.test.ts | 8 --- .../fleet-client/src/components/DiffView.tsx | 1 - .../src/components/TerminalGrid.tsx | 33 ++++------ .../src/components/WorkspaceNode.tsx | 4 -- .../src/components/WorkspacePanel.tsx | 6 +- .../fleet-client/src/components/ui/modal.tsx | 5 +- .../fleet-client/src/data/FleetContext.tsx | 13 +--- packages/fleet-client/src/data/eden.ts | 5 -- packages/fleet-client/src/data/mock.ts | 10 --- packages/fleet-client/src/data/provider.ts | 15 ----- packages/fleet-client/src/data/types.ts | 16 ++--- packages/fleet-client/src/data/useWebterm.ts | 8 +-- packages/fleet-client/src/frontend.tsx | 1 - packages/fleet-client/src/index.ts | 6 -- packages/fleet-client/src/layouts/Shell.tsx | 6 +- packages/fleet-client/src/layouts/Sidebar.tsx | 1 - packages/fleet-client/src/layouts/TopBar.tsx | 1 - packages/fleet-client/src/lib/armory.ts | 9 --- .../fleet-client/src/lib/diff/diff-target.ts | 10 --- .../fleet-client/src/lib/diff/parse-diff.ts | 8 --- .../fleet-client/src/lib/webterm/glyphs.ts | 13 ++-- packages/fleet-client/src/lib/webterm/keys.ts | 13 ++-- .../fleet-client/src/lib/webterm/palette.ts | 3 - .../fleet-client/src/routes/ArmoryRoute.tsx | 10 --- .../fleet-client/src/routes/ReposRoute.tsx | 1 - .../fleet-client/tests/armory-data.test.ts | 9 --- packages/fleet-protocol/index.ts | 5 -- packages/fleet-protocol/src/armory.ts | 51 ++++----------- packages/fleet-protocol/src/config.ts | 15 ----- packages/fleet-protocol/src/events.ts | 18 +----- packages/fleet-protocol/src/repo.ts | 10 --- packages/fleet-protocol/src/system.ts | 10 +-- packages/fleet-protocol/src/workspace.ts | 27 +------- packages/fleet-ship/src/api/armory.ts | 7 --- packages/fleet-ship/src/api/events.ts | 6 -- packages/fleet-ship/src/api/http.ts | 6 -- packages/fleet-ship/src/api/index.ts | 11 +--- .../fleet-ship/src/api/system-resources.ts | 9 --- packages/fleet-ship/src/api/workspaces.ts | 5 -- .../fleet-ship/src/armory/armory-cache.ts | 39 +----------- .../fleet-ship/src/armory/armory-installer.ts | 28 --------- packages/fleet-ship/src/armory/armory-sync.ts | 9 --- .../fleet-ship/src/armory/dotfile-linker.ts | 36 ++--------- packages/fleet-ship/src/atlas.ts | 8 --- packages/fleet-ship/src/config.ts | 9 --- packages/fleet-ship/src/index.ts | 4 -- packages/fleet-ship/src/managed-fs.ts | 2 - packages/fleet-ship/src/plugin-command.ts | 10 --- packages/fleet-ship/src/plugin-installer.ts | 31 +-------- packages/fleet-ship/src/providers.ts | 12 ---- packages/fleet-ship/src/skill-installer.ts | 14 +---- packages/fleet-ship/src/workspace-manager.ts | 13 ---- packages/fleet-ship/tests/api.test.ts | 7 --- .../fleet-ship/tests/armory-cache.test.ts | 10 +-- .../fleet-ship/tests/armory-installer.test.ts | 8 +-- packages/fleet-ship/tests/armory-sync.test.ts | 7 +-- packages/fleet-ship/tests/atlas.test.ts | 5 -- .../fleet-ship/tests/dotfile-linker.test.ts | 9 +-- packages/fleet-ship/tests/events-ws.test.ts | 8 --- packages/fleet-ship/tests/helpers.ts | 6 +- .../tests/managed-fs-remove.test.ts | 5 -- .../fleet-ship/tests/skill-installer.test.ts | 3 - .../tests/workspace-manager.test.ts | 2 - packages/git-bun/index.test.ts | 14 +---- packages/git-bun/index.ts | 9 --- packages/git-bun/src/backend.ts | 11 +--- packages/git-bun/src/command.ts | 9 +-- packages/git-bun/src/format.ts | 10 --- packages/git-bun/src/git.ts | 63 ++++--------------- packages/git-bun/src/types.ts | 29 --------- packages/tmux-bun/index.test.ts | 5 +- packages/tmux-bun/index.ts | 7 --- packages/tmux-bun/src/backend.ts | 10 +-- packages/tmux-bun/src/command.ts | 4 +- packages/tmux-bun/src/format.ts | 7 +-- packages/tmux-bun/src/pane.ts | 1 - packages/tmux-bun/src/session.ts | 3 +- packages/tmux-bun/src/target.ts | 5 -- packages/tmux-bun/src/tmux.ts | 16 ++--- packages/tmux-bun/src/types.ts | 13 +--- packages/webterm/encode.ts | 30 +++------ packages/webterm/index.ts | 8 +-- packages/webterm/protocol.ts | 61 ++++-------------- packages/webterm/server.ts | 50 ++++----------- 138 files changed, 190 insertions(+), 1584 deletions(-) diff --git a/apps/cli/src/attach.ts b/apps/cli/src/attach.ts index 5deb776..ae3fc2e 100644 --- a/apps/cli/src/attach.ts +++ b/apps/cli/src/attach.ts @@ -1,15 +1,3 @@ -/** - * attach.ts — `fleet client attach`: drive a workspace's webterm terminal from a - * real TTY. - * - * Output direction: the server streams snapshots and deltas, which a `GridStream` - * folds back into full grids for `renderGrid` to repaint (the renderer always - * paints the whole screen). Input direction is trivial — a TTY in raw mode - * already emits the exact byte sequences a PTY expects (arrows, ctrl chars, …), - * so we forward raw stdin straight through as `input` messages. Ctrl-] detaches - * without killing the shell (the tmux session survives for re-attach). - */ - import type { WorkspaceStatus } from "fleet-protocol"; import { decodeServerMessage, @@ -33,7 +21,6 @@ function terminalSize(): { cols: number; rows: number } { return { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 }; } -/** Build the terminal websocket URL from a normalized ship base URL. */ function terminalWsUrl(shipUrl: string, repo: string, name: string): string { const base = shipUrl.replace(/^http/, "ws"); return `${base}/workspaces/${encodeURIComponent(repo)}/${encodeURIComponent(name)}/terminal`; @@ -60,7 +47,6 @@ export function attachCloseOutcome( return { exitCode: 0 }; } -/** Ensure the workspace has a running session, activating it if inactive. */ async function ensureActive(shipUrl: string, repo: string, name: string): Promise { const client = makeClient(shipUrl); const status = unwrap(await client.workspaces({ repo })({ name }).get()) as WorkspaceStatus; @@ -70,10 +56,7 @@ async function ensureActive(shipUrl: string, repo: string, name: string): Promis } } -/** - * Attach to `repo/name`'s terminal until the shell exits or the user detaches. - * Resolves with the exit code to hand to `process.exit`. - */ +/** Resolves with the exit code to hand to `process.exit`. */ export async function attachToWorkspace(shipUrl: string, repo: string, name: string): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error("fleet: attach requires an interactive terminal"); diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index 9fae391..0bd5ead 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -1,9 +1,3 @@ -/** - * client.ts — the Eden Treaty client the CLI uses to talk to a Fleet Ship - * host, plus small helpers for normalizing the `--url` option and unwrapping - * Eden's `{ data, error }` result shape. - */ - import { treaty } from "@elysiajs/eden"; import type { App } from "fleet-ship/api"; import type { App as BridgeApp } from "fleet-bridge/api"; @@ -11,12 +5,10 @@ import type { App as BridgeApp } from "fleet-bridge/api"; export type FleetClient = ReturnType>; export type FleetBridgeClient = ReturnType>; -/** Build an Eden Treaty client pointed at a Fleet Ship `url` (already normalized). */ export function makeClient(url: string): FleetClient { return treaty(url); } -/** Build an Eden Treaty client pointed at a Fleet Bridge `url` (already normalized). */ export function makeBridgeClient(url: string): FleetBridgeClient { return treaty(url); } @@ -43,7 +35,6 @@ export function normalizeUrl(input: string): string { return `http://${trimmed}`; } -/** Shape every Eden Treaty call resolves to. */ export interface EdenResult { data: T | null; error: { status: number; value: unknown } | null; diff --git a/apps/cli/src/format.ts b/apps/cli/src/format.ts index 7cc0285..de26b8d 100644 --- a/apps/cli/src/format.ts +++ b/apps/cli/src/format.ts @@ -1,16 +1,8 @@ -/** - * format.ts — pure formatting helpers for CLI output (no network, no I/O). - */ - import type { ArmoryEntry, ArmorySyncState, WorkspaceSummary } from "fleet-protocol"; import type { Repo } from "fleet-protocol"; import type { ShipInfo, BridgeWorkspaceSummary, ShipArmoryState } from "fleet-bridge/types"; -/** - * Render an aligned, human-readable table: a header row followed by one row per - * entry, each column padded to its widest cell. With no rows, only the header is - * returned. - */ +/** With no rows, only the header row is returned. */ export function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string { const widths = headers.map((header, col) => Math.max(header.length, ...rows.map((row) => (row[col] ?? "").length)), @@ -22,7 +14,6 @@ export function renderTable(headers: readonly string[], rows: readonly (readonly return [formatRow(headers), ...rows.map((row) => formatRow(row))].join("\n"); } -/** Render a list of workspace summaries as an aligned, human-readable table. */ export function formatWorkspaceTable(rows: readonly WorkspaceSummary[]): string { return renderTable( ["REPO", "NAME", "BRANCH", "ACTIVE"], @@ -30,7 +21,6 @@ export function formatWorkspaceTable(rows: readonly WorkspaceSummary[]): string ); } -/** Render a fleet-wide workspace list, annotating each row with its owning ship. */ export function formatFleetWorkspaceTable(rows: readonly BridgeWorkspaceSummary[]): string { return renderTable( ["SHIP", "REPO", "NAME", "BRANCH", "ACTIVE"], @@ -38,7 +28,6 @@ export function formatFleetWorkspaceTable(rows: readonly BridgeWorkspaceSummary[ ); } -/** Render the bridge's registered ships as a table. */ export function formatShipTable(rows: readonly ShipInfo[]): string { return renderTable( ["NAME", "URL", "STATUS"], @@ -46,7 +35,6 @@ export function formatShipTable(rows: readonly ShipInfo[]): string { ); } -/** Render the bridge's registered repos as a table. */ export function formatRepoTable(rows: readonly Repo[]): string { return renderTable( ["NAME", "URL", "PROVIDER"], @@ -54,7 +42,6 @@ export function formatRepoTable(rows: readonly Repo[]): string { ); } -/** Placeholder for a column whose value does not exist yet. */ const MISSING = "-"; /** Revisions are 64 hex characters; 12 is plenty to compare two by eye. */ @@ -62,7 +49,6 @@ export function abbreviateRevision(revision: string | null): string { return revision ? revision.slice(0, 12) : MISSING; } -/** Where a ship stands against the bridge's armory. */ export type ArmoryShipState = "in sync" | "behind" | "never" | "error" | "unknown"; /** @@ -83,8 +69,6 @@ export function armoryShipState(bridgeRevision: string, state: ArmorySyncState | } /** - * Render a timestamp as ISO-8601, or the placeholder when there is none. - * * Accepts a `Date` despite `ArmorySyncState.syncedAt` being typed `string`: * Eden Treaty revives ISO strings in a response body into `Date` objects, so the * declared type is not what actually arrives. @@ -101,9 +85,8 @@ function formatMode(mode: number): string { } /** - * Render armory manifest entries as a table. `PATH` keeps its section prefix even - * though `SECTION` repeats it, so a row can be pasted straight into - * `fleet client armory cat`. + * `PATH` keeps its section prefix even though `SECTION` repeats it, so a row can + * be pasted straight into `fleet client armory cat`. */ export function formatArmoryTable(rows: readonly ArmoryEntry[]): string { return renderTable( @@ -112,7 +95,6 @@ export function formatArmoryTable(rows: readonly ArmoryEntry[]): string { ); } -/** Render each ship's armory state against the bridge's current `bridgeRevision`. */ export function formatArmoryShipTable( bridgeRevision: string, rows: readonly ShipArmoryState[], diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index b673bc3..3f7f845 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,11 +1,4 @@ #!/usr/bin/env bun -/** - * index.ts — Fleet CLI entry point. - * - * A Commander.js CLI that drives a Fleet Ship host's HTTP API through a - * type-safe Elysia Eden Treaty client (see client.ts). No terminal/websocket - * command — that's deliberately out of scope here. - */ import { Command } from "commander"; import { diff --git a/apps/cli/src/launch-command.ts b/apps/cli/src/launch-command.ts index 247611f..8a0dee0 100644 --- a/apps/cli/src/launch-command.ts +++ b/apps/cli/src/launch-command.ts @@ -1,11 +1,3 @@ -/** - * launch-command.ts — `fleet launch` and `fleet launch init`. - * - * `fleet launch` brings a whole fleet up in one process from a `fleet-config.yaml` - * (bridge + ships + gui), auto-registering each ship with the bridge. `fleet - * launch init` scaffolds a standard, commented config. - */ - import { Command } from "commander"; import { startBridge } from "fleet-bridge"; import { startShip } from "fleet-ship"; diff --git a/apps/cli/src/launch-config.ts b/apps/cli/src/launch-config.ts index 29019ed..5ab97fa 100644 --- a/apps/cli/src/launch-config.ts +++ b/apps/cli/src/launch-config.ts @@ -1,20 +1,8 @@ -/** - * launch-config.ts — the `fleet launch` configuration contract. - * - * `fleet launch` reads a single `fleet-config.yaml` describing a whole fleet: - * an optional `bridge`, an optional `gui`, and an optional map of `ships`. This - * module owns the zod schema, the YAML loader, and the normalization step that - * fills per-field defaults (a ship's `name`/`fleetDirectory` default from its - * map key) and validates cross-section constraints (unique local ports, a gui - * always has a bridge to reach). - */ - import { resolve } from "node:path"; import { parse } from "yaml"; import { z } from "zod"; import { FleetIdentifierSchema } from "fleet-protocol"; -/** Default bridge dataDirectory when the `bridge` section omits it. */ const DEFAULT_BRIDGE_DATA_DIRECTORY = "./.fleet/bridge"; const DEFAULT_BRIDGE_PORT = 4800; const DEFAULT_BRIDGE_NAME = "bridge"; @@ -41,7 +29,6 @@ const GuiSectionSchema = z.object({ bridgeUrl: z.string().min(1).optional(), }); -/** A ship the launch spawns itself (`source: local`, the default). */ const LocalShipSchema = z.object({ source: z.literal("local"), fleetDirectory: z.string().min(1).optional(), @@ -49,13 +36,11 @@ const LocalShipSchema = z.object({ name: FleetIdentifierSchema.optional(), }); -/** A ship already running elsewhere, registered by URL (`source: remote`). */ const RemoteShipSchema = z.object({ source: z.literal("remote"), url: z.string().min(1), }); -/** A ship entry — `source` defaults to `local` when omitted. */ const ShipSchema = z.preprocess( (value) => value && typeof value === "object" && !Array.isArray(value) && !("source" in value) @@ -112,11 +97,6 @@ export interface NormalizedLaunchConfig { ships: NormalizedShip[]; } -/** - * Validate and normalize a raw (already YAML-parsed) launch config: fill - * key-derived ship defaults, resolve `fleetDirectory` to an absolute path, and - * enforce cross-section constraints. Pure — no IO — so it's directly testable. - */ export function parseLaunchConfig(raw: unknown): NormalizedLaunchConfig { const parsed = LaunchConfigSchema.parse(raw); @@ -180,7 +160,6 @@ export function publicUrlWarning(config: NormalizedLaunchConfig): string | null ); } -/** Standard scaffold written by `fleet launch init` (commented for humans to edit). */ export const CONFIG_TEMPLATE = `# fleet-config.yaml — configuration for \`fleet launch\`. # Every section is optional; only the sections present are started. @@ -211,7 +190,6 @@ ships: # url: http://another-host:4700 `; -/** Read, parse, and normalize a `fleet-config.yaml` at `path`. */ export async function loadLaunchConfig(path: string): Promise { const file = Bun.file(path); if (!(await file.exists())) { diff --git a/apps/cli/src/render-grid.ts b/apps/cli/src/render-grid.ts index 741270f..c35d88f 100644 --- a/apps/cli/src/render-grid.ts +++ b/apps/cli/src/render-grid.ts @@ -1,16 +1,3 @@ -/** - * render-grid.ts — turn a webterm `GridMsg` snapshot into an ANSI frame the CLI - * writes to a real terminal. - * - * The webterm server is the VT emulator: it streams full active-screen grid - * snapshots (never raw PTY bytes), so the CLI has to repaint the screen itself. - * This is the terminal-side analog of fleet-client's canvas `TerminalGrid` — same - * cell semantics, but it emits SGR escapes instead of drawing to a canvas. - * - * Pure and side-effect-free (returns a string) so it can be unit-tested without a - * TTY or a socket. - */ - import { ATTR, type GridMsg, type WireCell, type WireColor } from "webterm/protocol"; const ESC = "\x1b["; @@ -23,7 +10,6 @@ function colorParams(color: WireColor | undefined, base: 38 | 48): string { return `${base};2;${color[0]};${color[1]};${color[2]}`; } -/** Full SGR sequence (`ESC[…m`) that styles a single non-blank cell. */ function cellSgr(cell: Exclude): string { const params: string[] = ["0"]; const attrs = cell.a ?? 0; @@ -57,10 +43,9 @@ function cursorShapeSeq(shape: GridMsg["cursor"]["shape"]): string { } /** - * Render a grid snapshot to a full ANSI frame. Repaints every row from the home - * position (`ESC[H`) using clear-to-EOL (`ESC[K`) rather than a full-screen clear, - * which avoids flicker. Each `GridMsg` is a complete snapshot, so a full repaint - * is always correct. + * Repaints every row from the home position (`ESC[H`) using clear-to-EOL + * (`ESC[K`) rather than a full-screen clear, which avoids flicker. Each + * `GridMsg` is a complete snapshot, so a full repaint is always correct. */ export function renderGrid(grid: GridMsg): string { let out = `${ESC}?25l${ESC}H`; // hide cursor while painting, home diff --git a/apps/fagent/src/agent-workspace.ts b/apps/fagent/src/agent-workspace.ts index b7863ac..d855e6a 100644 --- a/apps/fagent/src/agent-workspace.ts +++ b/apps/fagent/src/agent-workspace.ts @@ -1,9 +1,6 @@ /** - * Locate the fleet workspace containing a directory. - * - * The ship writes `atlas.json` to its data directory, while workspaces live at - * `//`. Walking upward finds the ship and derives the - * workspace identity from the first two path segments below it. + * The ship writes `atlas.json` to its data directory; workspaces live at + * `//`. */ import { dirname, join, relative, resolve, sep } from "node:path"; diff --git a/apps/fagent/src/client.ts b/apps/fagent/src/client.ts index f823512..917b634 100644 --- a/apps/fagent/src/client.ts +++ b/apps/fagent/src/client.ts @@ -1,16 +1,8 @@ -/** - * client.ts — the Eden Treaty client fagent uses to talk to a Fleet Bridge, - * plus small helpers for normalizing the `--bridge-url` option and unwrapping - * Eden's `{ data, error }` result shape. fagent only ever reaches the bridge - * (never a ship, never GitHub directly), so this is the bridge client only. - */ - import { treaty } from "@elysiajs/eden"; import type { App as BridgeApp } from "fleet-bridge/api"; export type FleetBridgeClient = ReturnType>; -/** Build an Eden Treaty client pointed at a Fleet Bridge `url` (already normalized). */ export function makeBridgeClient(url: string): FleetBridgeClient { return treaty(url); } @@ -37,7 +29,6 @@ export function normalizeUrl(input: string): string { return `http://${trimmed}`; } -/** Shape every Eden Treaty call resolves to. */ export interface EdenResult { data: T | null; error: { status: number; value: unknown } | null; diff --git a/apps/fagent/src/format.ts b/apps/fagent/src/format.ts index 9102ff8..07868ec 100644 --- a/apps/fagent/src/format.ts +++ b/apps/fagent/src/format.ts @@ -1,9 +1,3 @@ -/** - * format.ts — pure formatting helpers for `fagent repo` output (no network, - * no I/O). `renderTable` is copied from the fleet CLI (apps don't depend on - * each other); the repo formatters are typed against the bridge's provider DTOs. - */ - import type { CheckRun, Issue, @@ -14,9 +8,8 @@ import type { } from "fleet-bridge/providers"; /** - * Render an aligned, human-readable table: a header row followed by one row per - * entry, each column padded to its widest cell. With no rows, only the header is - * returned. + * Copied from the fleet CLI's `format.ts` — the apps do not depend on each + * other. With no rows, only the header row is returned. */ export function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string { const widths = headers.map((header, col) => @@ -30,15 +23,14 @@ export function renderTable(headers: readonly string[], rows: readonly (readonly } /** - * Render a timestamp field as ISO text. The bridge sends timestamps as ISO-8601 - * strings, but Eden's Treaty client auto-parses ISO strings in responses into - * `Date` objects, so the DTO's declared `string` may arrive as a `Date`. + * The bridge sends timestamps as ISO-8601 strings, but Eden's Treaty client + * auto-parses ISO strings in responses into `Date` objects, so the DTO's + * declared `string` may arrive as a `Date`. */ function isoText(value: string | Date): string { return value instanceof Date ? value.toISOString() : value; } -/** Render a repo's metadata as aligned `key: value` lines. */ export function formatRepoInfo(info: RepoInfo): string { const lines: [string, string][] = [ ["fullName", info.fullName], @@ -53,7 +45,6 @@ export function formatRepoInfo(info: RepoInfo): string { return lines.map(([key, value]) => `${key.padEnd(width)} ${value}`).join("\n"); } -/** Render a list of issue summaries as a table. */ export function formatIssueList(issues: IssueSummary[]): string { return renderTable( ["#", "TITLE", "STATE", "AUTHOR", "UPDATED"], @@ -67,7 +58,6 @@ export function formatIssueList(issues: IssueSummary[]): string { ); } -/** Render a single issue as a detail block: header, metadata, then the body. */ export function formatIssue(issue: Issue): string { return [ `#${issue.number} ${issue.title}`, @@ -82,7 +72,6 @@ export function formatIssue(issue: Issue): string { ].join("\n"); } -/** Render a list of pull request summaries as a table. */ export function formatPrList(prs: PullRequestSummary[]): string { return renderTable( ["#", "TITLE", "STATE", "AUTHOR", "BASE←HEAD", "DRAFT"], @@ -97,7 +86,6 @@ export function formatPrList(prs: PullRequestSummary[]): string { ); } -/** Render a list of CI check runs as a table. */ export function formatCheckList(checks: CheckRun[]): string { return renderTable( ["NAME", "STATUS", "CONCLUSION", "DETAILS"], @@ -110,7 +98,6 @@ export function formatCheckList(checks: CheckRun[]): string { ); } -/** Render a single pull request as a detail block: header, metadata, then the body. */ export function formatPr(pr: PullRequest): string { return [ `#${pr.number} ${pr.title}`, diff --git a/apps/fagent/src/index.ts b/apps/fagent/src/index.ts index 8b66bad..cfece6e 100644 --- a/apps/fagent/src/index.ts +++ b/apps/fagent/src/index.ts @@ -1,12 +1,4 @@ #!/usr/bin/env bun -/** - * index.ts — fagent CLI entry point. - * - * A standalone Commander.js CLI for fleet agents. It hosts the workspace - * reporting operations (`fagent agent init|status|in-workspace`) that agents - * run from inside a workspace to report their session and status back to the - * ship, plus `fagent repo …` for repo operations routed through the bridge. - */ import { Command } from "commander"; import { agentCommand } from "./agent-command"; diff --git a/apps/fagent/src/repo-command.ts b/apps/fagent/src/repo-command.ts index 8a1fa66..04bd2d7 100644 --- a/apps/fagent/src/repo-command.ts +++ b/apps/fagent/src/repo-command.ts @@ -1,12 +1,3 @@ -/** - * repo-command.ts — the `fagent repo …` command group. - * - * Repo operations (info, issues, PRs, comments, reviews) that an agent drives - * from inside a workspace. Everything flows THROUGH the fleet bridge — fagent - * never calls GitHub directly. The repo name is auto-detected from the current - * workspace path (via `findWorkspace`) unless overridden with `--repo`. - */ - import { Command } from "commander"; import type { CheckRun, @@ -42,10 +33,7 @@ function bridge() { return makeBridgeClient(normalizeUrl(repoCommand.opts().bridgeUrl)); } -/** - * Resolve the repo name to act on: the explicit `--repo`, else the repo of the - * workspace the CWD lives in. Exits 1 when neither is available. - */ +/** Exits 1 when there is neither a `--repo` nor a workspace to infer one from. */ async function resolveRepo(): Promise { const override = repoCommand.opts().repo as string | undefined; if (override) return override; diff --git a/apps/fagent/tests/repo-command.test.ts b/apps/fagent/tests/repo-command.test.ts index 7aaeec4..32488c3 100644 --- a/apps/fagent/tests/repo-command.test.ts +++ b/apps/fagent/tests/repo-command.test.ts @@ -85,7 +85,6 @@ const checkRun = { const failedLog = { workflow: "CI", job: "test", jobId: 901, log: "boom: the test failed" }; -/** Stand up a fake bridge that records every request and returns canned DTOs. */ function makeFakeBridge(overrides?: (path: string) => Response | undefined) { const requests: RecordedRequest[] = []; const server = Bun.serve({ diff --git a/packages/bun-vt/src/cell.ts b/packages/bun-vt/src/cell.ts index a03476f..d7a35df 100644 --- a/packages/bun-vt/src/cell.ts +++ b/packages/bun-vt/src/cell.ts @@ -1,14 +1,3 @@ -/** - * src/cell.ts — the cell model. - * - * `Cell` is the immutable snapshot returned by `Terminal.cell()`; it mirrors the - * shape produced by libghostty-bun so this port is a drop-in replacement. - * - * `Pen` is the mutable internal storage the grid keeps per cell. It also doubles - * as the "current graphic rendition" (the active SGR state the cursor writes - * with): printing copies the cursor's pen into the target cell. - */ - import { type Color, DEFAULT_COLOR, colorsEqual } from "./color"; export type UnderlineStyle = @@ -58,7 +47,6 @@ export interface CellStyle { readonly underline: UnderlineStyle; } -/** A single terminal grid cell snapshot (public, immutable). */ export interface Cell { /** The primary character of the cell, or "" if the cell is empty. */ readonly char: string; @@ -71,10 +59,8 @@ export interface Cell { readonly style: CellStyle; } -/** - * Mutable per-cell storage. Kept as a small class (not an object literal) so - * rows are arrays of homogeneous instances; the grid reuses these in place. - */ +// Kept as a class (not an object literal) so rows are arrays of homogeneous +// instances; the grid reuses these in place rather than reallocating. export class Pen { cp = 0; wide: number = Wide.NARROW; @@ -98,7 +84,6 @@ export class Pen { this.wide = Wide.NARROW; } - /** Reset everything to defaults (blank cell, default attributes). */ reset(): void { this.cp = 0; this.wide = Wide.NARROW; @@ -130,7 +115,6 @@ export class Pen { this.underline = 0; } - /** Copy graphic rendition (colors + attributes) from another pen. */ copyAttributesFrom(o: Pen): void { this.fg = o.fg; this.bg = o.bg; @@ -145,7 +129,6 @@ export class Pen { this.underline = o.underline; } - /** Copy the full cell (glyph + width + attributes) from another pen. */ copyFrom(o: Pen): void { this.cp = o.cp; this.wide = o.wide; @@ -168,7 +151,6 @@ export class Pen { ); } - /** Produce the immutable public snapshot for this cell. */ toCell(): Cell { const cp = this.cp; return { diff --git a/packages/bun-vt/src/color.ts b/packages/bun-vt/src/color.ts index 617b599..1c77a04 100644 --- a/packages/bun-vt/src/color.ts +++ b/packages/bun-vt/src/color.ts @@ -1,18 +1,3 @@ -/** - * src/color.ts — terminal color model. - * - * A cell foreground/background color is a tagged union, mirroring how - * libghostty-vt models `style.Color`: - * - `default` — use the terminal default (no explicit color set). - * - `palette` — an index 0..255 into the 256-color palette. Indices 0..15 are - * the named ANSI colors (index 1 == red), 16..231 the 6×6×6 color cube, and - * 232..255 the grayscale ramp. - * - `rgb` — a 24-bit true color. - * - * SGR 30–37 / 90–97 (and the bg equivalents) select *palette* colors, not RGB — - * so `\x1b[31m` yields `{ type: "palette", index: 1 }`, matching Ghostty. - */ - export type Color = | { readonly type: "default" } | { readonly type: "palette"; readonly index: number } diff --git a/packages/bun-vt/src/index.ts b/packages/bun-vt/src/index.ts index 37183a6..39b4749 100644 --- a/packages/bun-vt/src/index.ts +++ b/packages/bun-vt/src/index.ts @@ -1,19 +1,3 @@ -/** - * bun-vt — a pure-TypeScript port of libghostty's VT terminal emulation. - * - * No native code, no FFI: the VT500 parser, the screen/grid model, and all - * escape-sequence semantics are implemented in TypeScript. The public API - * mirrors libghostty-bun's `Terminal`, so it is a drop-in replacement: - * - * ```ts - * import { Terminal } from "bun-vt"; - * - * using term = new Terminal({ cols: 80, rows: 24 }); - * term.write("\x1b[31mhi"); - * console.log(term.cell(0, 0).char); // "h" - * ``` - */ - export { Terminal, type Cell, @@ -26,7 +10,6 @@ export { type UnderlineStyle, } from "./terminal"; -// Lower-level building blocks, for advanced use / testing. export { Parser, type Handler, type CsiSequence, type EscSequence } from "./parser"; export { Screen } from "./screen"; export { wcwidth } from "./wcwidth"; diff --git a/packages/bun-vt/src/parser.ts b/packages/bun-vt/src/parser.ts index 463bf67..7b162a4 100644 --- a/packages/bun-vt/src/parser.ts +++ b/packages/bun-vt/src/parser.ts @@ -1,22 +1,6 @@ -/** - * src/parser.ts — a VT500-series escape-sequence parser. - * - * This is a faithful implementation of Paul Williams' DEC-compatible parser - * state machine (the same design libghostty's `Parser.zig` is built on), - * extended with: - * - UTF-8 decoding in the ground state (so `print` delivers Unicode scalars, - * not raw bytes), and - * - colon-separated CSI sub-parameters (needed for SGR forms like `4:3` and - * `38:2::r:g:b`). - * - * The parser is a pure byte→action translator: it never touches terminal state. - * It drives a `Handler` via callbacks, exactly like a SAX parser. All terminal - * semantics live in the handler (see terminal.ts). - * - * Robustness: the machine is hardened against arbitrary/malformed input — every - * byte has a defined transition and nothing throws. This matches the guarantee - * libghostty-vt makes about untrusted data. - */ +// State machine follows Paul Williams' DEC-compatible VT500 parser, extended +// with ground-state UTF-8 decoding and colon-separated CSI sub-parameters. +// Every byte has a defined transition; nothing throws on malformed input. export interface CsiSequence { /** Numeric parameters. An omitted parameter is 0 (handlers apply defaults). */ @@ -88,7 +72,6 @@ function isExecutable(b: number): boolean { export class Parser { #state: S = S.GROUND; - // -- CSI / escape accumulators -- // `#params`/`#colon` hold already-finalized parameters. `#curParam` is the // in-progress parameter being accumulated; `#curColon` records the separator // that preceded it (a colon makes it a sub-parameter of the prior group). @@ -101,10 +84,8 @@ export class Parser { #prefix = ""; #overflow = false; // too many params → dispatch is dropped - // -- OSC accumulator (raw bytes, decoded as UTF-8 at completion) -- #osc: number[] = []; - // -- UTF-8 ground decoder -- #utf8Remaining = 0; #utf8Cp = 0; @@ -117,13 +98,11 @@ export class Parser { this.#utf8Cp = 0; } - /** Feed a whole buffer. */ write(bytes: Uint8Array): void { for (let i = 0; i < bytes.length; i++) this.next(bytes[i]!); } next(b: number): void { - // --- UTF-8 continuation handling (only meaningful in the ground state) --- if (this.#utf8Remaining > 0) { if (b >= 0x80 && b <= 0xbf) { this.#utf8Cp = (this.#utf8Cp << 6) | (b & 0x3f); @@ -133,7 +112,6 @@ export class Parser { // Malformed sequence: emit a replacement char and reprocess this byte. this.#utf8Remaining = 0; this.h.print(REPLACEMENT); - // fall through } // --- Anywhere transitions --- @@ -200,8 +178,6 @@ export class Parser { } } - // --- ground ------------------------------------------------------------- - #ground(b: number): void { if (isExecutable(b)) { this.h.execute(b); @@ -237,8 +213,6 @@ export class Parser { this.h.print(cp); } - // --- escape ------------------------------------------------------------- - #escape(b: number): void { if (isExecutable(b)) return this.h.execute(b); if (b === 0x7f) return; @@ -274,7 +248,6 @@ export class Parser { this.#clear(); return; } - // Anything else: back to ground. this.#state = S.GROUND; this.#clear(); } @@ -301,8 +274,6 @@ export class Parser { } } - // --- CSI ---------------------------------------------------------------- - #csiEntry(b: number): void { if (isExecutable(b)) return this.h.execute(b); if (b === 0x7f) return; @@ -385,8 +356,6 @@ export class Parser { this.#clear(); } - // --- DCS ---------------------------------------------------------------- - #dcsEntry(b: number): void { if (b === 0x7f) return; if (b >= 0x40 && b <= 0x7e) return this.#dcsHook(b); @@ -482,8 +451,6 @@ export class Parser { } } - // --- OSC ---------------------------------------------------------------- - #oscString(b: number): void { if (b === 0x07) { // BEL terminator @@ -526,8 +493,6 @@ export class Parser { this.h.oscDispatch(data); } - // --- SOS/PM/APC (consumed and ignored) ---------------------------------- - #sosPmApc(b: number): void { if (b === 0x9c) { this.#state = S.GROUND; @@ -536,8 +501,6 @@ export class Parser { // ESC handled by the anywhere transition (→ escape, then ST no-ops). } - // --- param helpers ------------------------------------------------------ - #pushDigit(b: number): void { if (this.#overflow) return; this.#hasDigits = true; @@ -545,7 +508,6 @@ export class Parser { if (this.#curParam > 0xffff) this.#curParam = 0xffff; // clamp, matches xterm } - /** Finalize the current parameter and open a new one after a separator. */ #nextParam(isColon: boolean): void { if (this.#overflow) return; if (this.#params.length >= MAX_PARAMS) { @@ -559,7 +521,6 @@ export class Parser { this.#curColon = isColon; } - /** Finalize the trailing parameter at dispatch time. */ #commitParam(): void { if (this.#overflow) return; // Push the final parameter unless the whole sequence was empty (e.g. `CSI m`). diff --git a/packages/bun-vt/src/screen.ts b/packages/bun-vt/src/screen.ts index 03f5398..4ef3180 100644 --- a/packages/bun-vt/src/screen.ts +++ b/packages/bun-vt/src/screen.ts @@ -1,21 +1,4 @@ -/** - * src/screen.ts — the terminal grid and all mutating operations. - * - * A `Screen` owns: - * - the visible grid (`rows` × `cols` of `Pen` cells), - * - the cursor (position, pending-wrap flag, and the active graphic rendition), - * - a scroll region (DECSTBM) and tab stops, - * - scrollback (lines that scroll off the top of a full-screen scroll), - * - a primary/alternate buffer pair (DEC modes 47/1047/1049). - * - * Every editing primitive a VT terminal needs lives here as a method; the - * Terminal handler (terminal.ts) translates parsed escape sequences into these - * calls. This mirrors the split in libghostty between the parser/stream and - * `Screen`/`Terminal`. - * - * Coordinates are 0-indexed. Erasing uses the current background color (BCE), - * matching xterm/Ghostty: a blank produced by an erase keeps the pen's `bg`. - */ +// Coordinates are 0-indexed throughout; the VT wire protocol is 1-indexed. import { Pen, Wide } from "./cell"; import { DEFAULT_COLOR, type Color } from "./color"; @@ -64,7 +47,6 @@ export class Screen { scrollTop = 0; scrollBottom: number; - // DEC private modes. cursorVisible = true; cursorShape: CursorShape = "block"; cursorBlinking = true; @@ -95,8 +77,6 @@ export class Screen { return stops; } - // --- cell access -------------------------------------------------------- - /** The cell at (row, col) in the visible area, or null if out of bounds. */ cellAt(row: number, col: number): Pen | null { if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) return null; @@ -115,8 +95,6 @@ export class Screen { return row; } - // --- printing ----------------------------------------------------------- - print(cp: number): void { const w = wcwidth(cp); @@ -145,7 +123,6 @@ export class Screen { const row = this.grid[this.cursor.y]!; const cell = row[this.cursor.x]!; - // If we overwrite the head of an existing wide pair, clear its orphaned tail. this.#clearWideNeighbors(row, this.cursor.x); cell.copyAttributesFrom(this.cursor.pen); @@ -182,8 +159,6 @@ export class Screen { } } - // --- cursor movement ---------------------------------------------------- - #index(): void { // Line feed within the scroll region. if (this.cursor.y === this.scrollBottom) { @@ -271,8 +246,6 @@ export class Screen { return Math.min(this.rows - 1, Math.max(0, y)); } - // --- scrolling ---------------------------------------------------------- - /** Scroll the scroll region up by `n` lines (content moves up). */ scrollUp(n: number): void { const top = this.scrollTop; @@ -287,11 +260,9 @@ export class Screen { if (intoScrollback) this.#pushScrollback(leaving); } - // Shift rows up within the region. for (let y = top; y <= bottom - count; y++) { this.grid[y] = this.grid[y + count]!; } - // Fill the vacated bottom rows with blanks. for (let y = bottom - count + 1; y <= bottom; y++) { this.grid[y] = this.#blankRow(); } @@ -332,8 +303,6 @@ export class Screen { this.setCursor(0, 0); } - // --- line/char editing -------------------------------------------------- - insertLines(n: number): void { if (this.cursor.y < this.scrollTop || this.cursor.y > this.scrollBottom) return; const bottom = this.scrollBottom; @@ -391,8 +360,6 @@ export class Screen { this.cursor.pendingWrap = false; } - // --- erasing ------------------------------------------------------------ - eraseLine(mode: number): void { const row = this.grid[this.cursor.y]!; let from = 0; @@ -426,8 +393,6 @@ export class Screen { this.cursor.pendingWrap = false; } - // --- tabs --------------------------------------------------------------- - tab(n = 1): void { for (let i = 0; i < n; i++) { let x = this.cursor.x + 1; @@ -455,8 +420,6 @@ export class Screen { else if (this.cursor.x < this.cols) this.tabStops[this.cursor.x] = false; } - // --- saved cursor (DECSC/DECRC) ----------------------------------------- - saveCursor(): void { this.#saved = this.#snapshotCursor(); } @@ -492,13 +455,10 @@ export class Screen { this.cursor.pendingWrap = s.pendingWrap; } - // --- alternate screen --------------------------------------------------- - enterAlt(saveCursor: boolean, clear: boolean): void { if (this.onAlt) return; if (saveCursor) this.#savedForAlt = this.#snapshotCursor(); this.#altGrid = Array.from({ length: this.rows }, () => this.#blankRow()); - // Swap the primary grid out; keep it referenced for restoreAlt. this.#primaryGrid = this.grid; this.grid = this.#altGrid; this.onAlt = true; @@ -521,8 +481,6 @@ export class Screen { this.#savedForAlt = null; } - // --- reset -------------------------------------------------------------- - reset(): void { if (this.onAlt) this.leaveAlt(false); this.grid = Array.from({ length: this.rows }, () => makeRow(this.cols)); @@ -543,8 +501,6 @@ export class Screen { this.cursor.pen.reset(); } - // --- resize ------------------------------------------------------------- - /** * Resize the grid to `cols` × `rows`. Rows are padded/truncated in place * (no reflow), extra bottom rows are added blank, and surplus rows above are diff --git a/packages/bun-vt/src/sgr.ts b/packages/bun-vt/src/sgr.ts index 86bba24..819a892 100644 --- a/packages/bun-vt/src/sgr.ts +++ b/packages/bun-vt/src/sgr.ts @@ -1,15 +1,6 @@ -/** - * src/sgr.ts — Select Graphic Rendition (CSI … m) application. - * - * Applies a CSI SGR parameter list to a `Pen`, supporting both the classic - * semicolon form (`38;2;r;g;b`, `38;5;n`) and the ISO 8613-6 colon sub-parameter - * form (`38:2::r:g:b`, `38:5:n`, `4:3` for underline styles). - * - * The two forms are disambiguated by first splitting the flat parameter list - * into *groups*: consecutive parameters joined by colons form one group; a - * semicolon starts a new group. Extended-color codes then read either the rest - * of their own group (colon form) or the following groups (semicolon form). - */ +// Params are first split into groups (colon-joined params form one group) so the +// ISO 8613-6 colon form (`38:2::r:g:b`) and the classic semicolon form +// (`38;2;r;g;b`) can be told apart at dispatch. import { type Pen } from "./cell"; import { DEFAULT_COLOR, palette, rgb, NamedColor } from "./color"; diff --git a/packages/bun-vt/src/terminal.ts b/packages/bun-vt/src/terminal.ts index 9c82975..d94122d 100644 --- a/packages/bun-vt/src/terminal.ts +++ b/packages/bun-vt/src/terminal.ts @@ -1,13 +1,6 @@ -/** - * src/terminal.ts — the public Terminal API and the parser Handler. - * - * `Terminal` owns a `Parser` and a `Screen`. It implements the parser's - * `Handler` interface, translating parsed VT actions (print / execute / CSI / - * ESC / OSC) into `Screen` mutations. The public surface intentionally mirrors - * libghostty-bun's `Terminal` so this pure-TypeScript port is a drop-in - * replacement — `write`, `cell`, `cursor`, `resize`, `reset`, `rowText`, - * `cols`, `rows`, and `free`/`Symbol.dispose`. - */ +// The public surface intentionally mirrors libghostty-bun's `Terminal` so this +// pure-TypeScript port is a drop-in replacement; that constraint explains the +// otherwise-unused `resize` pixel args and the `free`/`Symbol.dispose` pair. import { Parser, type CsiSequence, type EscSequence, type Handler } from "./parser"; import { Screen, type CursorShape } from "./screen"; @@ -51,7 +44,6 @@ function parseOscColor(value: string): Color | null { return rgb(component(x11[1]!), component(x11[2]!), component(x11[3]!)); } -// C0 control bytes. const BEL = 0x07; const BS = 0x08; const HT = 0x09; @@ -156,8 +148,6 @@ export class Terminal implements Handler { this.free(); } - // === Handler implementation =========================================== - print(cp: number): void { this.#screen.print(cp); } @@ -361,8 +351,6 @@ export class Terminal implements Handler { } } - // --- DEC private / ANSI modes ------------------------------------------ - #setModes(params: readonly number[], set: boolean): void { for (const mode of params) this.#setMode(mode, set); } diff --git a/packages/bun-vt/src/wcwidth.ts b/packages/bun-vt/src/wcwidth.ts index 8e82d22..6c4ac12 100644 --- a/packages/bun-vt/src/wcwidth.ts +++ b/packages/bun-vt/src/wcwidth.ts @@ -1,16 +1,5 @@ -/** - * src/wcwidth.ts — display width of a Unicode scalar value. - * - * Returns the number of terminal cells a codepoint occupies: - * - 0 for combining marks / zero-width characters, - * - 2 for East Asian wide & fullwidth characters and most emoji, - * - 1 otherwise. - * - * This is a compact implementation covering the ranges that matter for terminal - * rendering. It is not a full Unicode grapheme segmenter (Ghostty ships a - * generated table); it is faithful for the common cases — ASCII, CJK, combining - * marks and emoji — which is what the terminal grid needs to place cells. - */ +// A compact approximation, not a generated Unicode width table: it covers +// ASCII, CJK, combining marks and emoji, which is what grid placement needs. type Range = readonly [number, number]; diff --git a/packages/bun-vt/test/acceptance.test.ts b/packages/bun-vt/test/acceptance.test.ts index dd0cc3e..968e36b 100644 --- a/packages/bun-vt/test/acceptance.test.ts +++ b/packages/bun-vt/test/acceptance.test.ts @@ -1,7 +1,5 @@ -/** - * test/acceptance.test.ts — the same acceptance criteria libghostty-bun ships, - * proving this pure-TS port is behaviourally compatible. - */ +// These are the acceptance criteria libghostty-bun ships; keep them in sync +// with that package rather than relaxing them to match this implementation. import { test, expect, describe } from "bun:test"; import { Terminal } from "../src/index"; diff --git a/packages/bun-vt/test/cursor.test.ts b/packages/bun-vt/test/cursor.test.ts index 09640ef..bca939c 100644 --- a/packages/bun-vt/test/cursor.test.ts +++ b/packages/bun-vt/test/cursor.test.ts @@ -1,7 +1,3 @@ -/** - * test/cursor.test.ts — cursor movement, editing, and erasing. - */ - import { test, expect, describe } from "bun:test"; import { Terminal } from "../src/index"; diff --git a/packages/bun-vt/test/modes.test.ts b/packages/bun-vt/test/modes.test.ts index 50061d8..ae0ecd8 100644 --- a/packages/bun-vt/test/modes.test.ts +++ b/packages/bun-vt/test/modes.test.ts @@ -1,8 +1,3 @@ -/** - * test/modes.test.ts — DEC private modes: autowrap, origin, cursor visibility, - * alternate screen; plus RIS reset. - */ - import { test, expect, describe } from "bun:test"; import { Screen, Terminal } from "../src/index"; diff --git a/packages/bun-vt/test/parser.test.ts b/packages/bun-vt/test/parser.test.ts index e56c99b..ac64acf 100644 --- a/packages/bun-vt/test/parser.test.ts +++ b/packages/bun-vt/test/parser.test.ts @@ -1,7 +1,3 @@ -/** - * test/parser.test.ts — the VT500 parser state machine in isolation. - */ - import { test, expect, describe } from "bun:test"; import { Parser, type CsiSequence, type EscSequence } from "../src/parser"; diff --git a/packages/bun-vt/test/scroll.test.ts b/packages/bun-vt/test/scroll.test.ts index 9c39555..573e232 100644 --- a/packages/bun-vt/test/scroll.test.ts +++ b/packages/bun-vt/test/scroll.test.ts @@ -1,7 +1,3 @@ -/** - * test/scroll.test.ts — scrolling, scroll regions (DECSTBM), and scrollback. - */ - import { test, expect, describe } from "bun:test"; import { Screen, Terminal } from "../src/index"; @@ -66,8 +62,6 @@ describe("scrollback", () => { test("respects the max-scrollback bound (no unbounded growth)", () => { const t = new Terminal({ cols: 5, rows: 2, maxScrollback: 3 }); for (let i = 0; i < 100; i++) t.write(`${i}\r\n`); - // Visible area still shows the two most recent lines; the emulator did not - // throw and the terminal remains usable. expect(t.rows).toBe(2); expect(t.cursor().y).toBe(1); }); diff --git a/packages/bun-vt/test/sgr.test.ts b/packages/bun-vt/test/sgr.test.ts index ad443a7..cab847a 100644 --- a/packages/bun-vt/test/sgr.test.ts +++ b/packages/bun-vt/test/sgr.test.ts @@ -1,7 +1,3 @@ -/** - * test/sgr.test.ts — Select Graphic Rendition: colors and attributes. - */ - import { test, expect, describe } from "bun:test"; import { Terminal } from "../src/index"; diff --git a/packages/bun-vt/test/unicode.test.ts b/packages/bun-vt/test/unicode.test.ts index 54e1db6..3374690 100644 --- a/packages/bun-vt/test/unicode.test.ts +++ b/packages/bun-vt/test/unicode.test.ts @@ -1,9 +1,5 @@ -/** - * test/unicode.test.ts — UTF-8 decoding, wide characters, combining marks. - * - * Characters are written with explicit \u/\u{} escapes so the test is immune to - * whatever normalization form the editor stores the source file in. - */ +// Characters are written with explicit \u/\u{} escapes so the test is immune to +// whatever normalization form the editor stores the source file in. import { test, expect, describe } from "bun:test"; import { Screen, Terminal, wcwidth } from "../src/index"; diff --git a/packages/fleet-bridge/src/api/armory.ts b/packages/fleet-bridge/src/api/armory.ts index 9e356f5..1a9bdc6 100644 --- a/packages/fleet-bridge/src/api/armory.ts +++ b/packages/fleet-bridge/src/api/armory.ts @@ -1,11 +1,3 @@ -/** - * api/armory.ts — the read side of the Armory: the manifest of the bridge's - * `armory/` directory, the contents of any file it lists, and what each ship has - * applied. Ships poll the first two to decide whether to re-pull; the last is for - * operators watching the fleet converge. One Elysia chain so route types stay - * inferable for Eden. - */ - import { Elysia, t } from "elysia"; import type { FleetManager } from "../fleet-manager"; import { mapError } from "./http"; diff --git a/packages/fleet-bridge/src/api/http.ts b/packages/fleet-bridge/src/api/http.ts index a510c47..5d71da0 100644 --- a/packages/fleet-bridge/src/api/http.ts +++ b/packages/fleet-bridge/src/api/http.ts @@ -1,10 +1,3 @@ -/** - * api/http.ts — shared HTTP error mapping for the bridge's Elysia plugins. - * - * Mirrors the ship's `mapError`: a `BridgeError` (or a provider-layer - * `ProviderError`) carries the status to surface; anything else is a 500. - */ - import { BridgeError } from "../fleet-manager"; import { ProviderError } from "../providers"; diff --git a/packages/fleet-bridge/src/api/index.ts b/packages/fleet-bridge/src/api/index.ts index a47f445..77a62d3 100644 --- a/packages/fleet-bridge/src/api/index.ts +++ b/packages/fleet-bridge/src/api/index.ts @@ -1,11 +1,3 @@ -/** - * api/index.ts — composes the bridge's Elysia app from its route plugins. - * - * Each plugin is a single Elysia chain, so `.use()` merges its route types - * into the parent and `App = ReturnType` carries the full - * merged surface for a future Eden `treaty` client. - */ - import { Elysia } from "elysia"; import { MAX_CLIENT_FRAME_BYTES } from "webterm/protocol"; import type { FleetManager } from "../fleet-manager"; diff --git a/packages/fleet-bridge/src/api/repos.ts b/packages/fleet-bridge/src/api/repos.ts index f5055a8..c1e9345 100644 --- a/packages/fleet-bridge/src/api/repos.ts +++ b/packages/fleet-bridge/src/api/repos.ts @@ -1,9 +1,3 @@ -/** - * api/repos.ts — the bridge's repo registry: list, register, and remove the repos - * the fleet can create workspaces from. One Elysia chain so route types stay - * inferable for Eden. - */ - import { Elysia, t } from "elysia"; import type { FleetManager } from "../fleet-manager"; import { mapError } from "./http"; @@ -189,12 +183,10 @@ export function reposPlugin(manager: FleetManager) { /** `:number` path param coerced to a number; non-numeric values are rejected (422). */ const numberParams = t.Object({ name: t.String(), number: t.Numeric() }); -/** Optional `?state=open|closed|all` filter shared by the list endpoints. */ const stateQuery = t.Object({ state: t.Optional(t.Union([t.Literal("open"), t.Literal("closed"), t.Literal("all")])), }); -/** `?ref=` or `?pr=` — the target a checks/logs query resolves. */ const checkTargetQuery = t.Object({ ref: t.Optional(t.String()), pr: t.Optional(t.Numeric()), diff --git a/packages/fleet-bridge/src/api/ships.ts b/packages/fleet-bridge/src/api/ships.ts index be759ed..e0d41a0 100644 --- a/packages/fleet-bridge/src/api/ships.ts +++ b/packages/fleet-bridge/src/api/ships.ts @@ -1,8 +1,3 @@ -/** - * api/ships.ts — the bridge-only ship-management routes. One Elysia chain so - * route types stay inferable for Eden. - */ - import { Elysia, t } from "elysia"; import type { FleetManager } from "../fleet-manager"; import { mapError } from "./http"; diff --git a/packages/fleet-bridge/src/api/system-resources.ts b/packages/fleet-bridge/src/api/system-resources.ts index b0b3a27..e470185 100644 --- a/packages/fleet-bridge/src/api/system-resources.ts +++ b/packages/fleet-bridge/src/api/system-resources.ts @@ -1,9 +1,3 @@ -/** - * api/system-resources.ts — the bridge's system-resources routes: an aggregate - * across all ships, plus a per-ship proxy. One Elysia chain so route types stay - * inferable for Eden. - */ - import { Elysia } from "elysia"; import type { FleetManager } from "../fleet-manager"; import { mapError } from "./http"; diff --git a/packages/fleet-bridge/src/api/workspaces.ts b/packages/fleet-bridge/src/api/workspaces.ts index 396c2de..5dae663 100644 --- a/packages/fleet-bridge/src/api/workspaces.ts +++ b/packages/fleet-bridge/src/api/workspaces.ts @@ -1,10 +1,3 @@ -/** - * api/workspaces.ts — the bridge's workspace routes: a superset of the ship's - * workspace API, with the owning ship abstracted away (routing handled by the - * `FleetManager`) but kept visible on every response. Built as one Elysia chain - * so route types stay inferable for Eden. - */ - import { Elysia, t } from "elysia"; import { BINARY_MESSAGE_CLOSE_CODE, diff --git a/packages/fleet-bridge/src/armory/armory-service.ts b/packages/fleet-bridge/src/armory/armory-service.ts index b4c8e32..0ef99c6 100644 --- a/packages/fleet-bridge/src/armory/armory-service.ts +++ b/packages/fleet-bridge/src/armory/armory-service.ts @@ -1,25 +1,3 @@ -/** - * armory/armory-service.ts — scans `/armory` into a content-addressed - * `ArmoryManifest` and serves individual files out of it. - * - * Read-only and human-authored: the directory is hand-edited or git-synced, so the - * scan is defensive rather than trusting. Symlinks at or below the section level - * are skipped outright (never followed, never listed) — including a section - * directory that is itself a symlink — because such a link would let a manifest - * consumer pull a file from anywhere on the bridge host, and the rest of this - * codebase refuses symlinks for the same reason. The one exception is the armory - * root, which the operator may point wherever they like; see `scan`. - * - * The manifest gates which paths `readFile` will serve, but it does not by itself - * confine reads: it is cached, so it describes the tree as of the last scan, and a - * directory that was real then may be a symlink now. `readFile` therefore - * re-resolves the file against the armory root before reading a byte. - * - * A scan is cached until `invalidate()` (a filesystem watcher calls it) and - * serialized through a promise queue, mirroring `store.ts`, so concurrent - * requests never walk the tree simultaneously. - */ - import { lstat, readdir, realpath } from "node:fs/promises"; import { join, relative, resolve, sep } from "node:path"; import { @@ -38,7 +16,6 @@ import { /** Ceiling on a single `readFile`; oversized files are still listed in the manifest. */ export const MAX_ARMORY_FILE_BYTES = 10 * 1024 * 1024; -/** Names never worth shipping, skipped wherever they appear in the tree. */ const IGNORED_NAMES = new Set([".git", ".DS_Store"]); export class ArmoryPathError extends Error { @@ -99,7 +76,6 @@ export class ArmoryService { return result; } - /** The current manifest, scanning only when the cache is cold. */ async manifest(): Promise { return this.serialized(async () => { if (this.cached) return this.cached; @@ -108,7 +84,6 @@ export class ArmoryService { }); } - /** Drop the cached manifest so the next `manifest()` rescans. */ invalidate(): void { this.cached = undefined; } @@ -159,8 +134,6 @@ export class ArmoryService { : { path, section, size, sha256, mode, encoding: "utf8", contents: text }; } - // --- scanning ------------------------------------------------------------- - /** * The root and the sections are trusted differently, which is worth stating * because the asymmetry looks arbitrary. The root is the operator's own choice diff --git a/packages/fleet-bridge/src/armory/armory-watcher.ts b/packages/fleet-bridge/src/armory/armory-watcher.ts index 10e7c46..e49c4d8 100644 --- a/packages/fleet-bridge/src/armory/armory-watcher.ts +++ b/packages/fleet-bridge/src/armory/armory-watcher.ts @@ -1,17 +1,3 @@ -/** - * armory/armory-watcher.ts — notices that the bridge's `armory/` directory - * changed and says so, once. - * - * The armory is hand-edited or `git pull`ed, so a single logical change arrives - * as a burst of filesystem events; the debounce collapses that burst into one - * callback, which is what keeps a `git pull` from fanning a push per file out - * to every ship in the fleet. - * - * Nothing here may take the bridge down. The armory is optional, so a missing - * directory yields a silent no-op handle, and a watch error is logged and - * swallowed — a bridge that cannot watch its armory still routes workspaces. - */ - import { watch } from "node:fs"; const DEFAULT_DEBOUNCE_MS = 250; diff --git a/packages/fleet-bridge/src/config.ts b/packages/fleet-bridge/src/config.ts index d827ef3..fea2019 100644 --- a/packages/fleet-bridge/src/config.ts +++ b/packages/fleet-bridge/src/config.ts @@ -1,23 +1,10 @@ -/** - * config.ts — the Fleet Bridge configuration contract. - * - * The bridge is configured entirely from CLI flags (see `index.ts`); this file - * owns the canonical shape (`BridgeConfigSchema`) and validates a flag-assembled - * object against it, resolving `dataDirectory` to an absolute path. The shape is - * a small bridge-only zod schema (the ship's config schema lives in the shared - * `fleet-protocol` package; the bridge's is not shared, so it stays here). - */ - import { resolve } from "node:path"; import { z } from "zod"; -/** Runtime validator for the bridge configuration. */ export const BridgeConfigSchema = z.object({ /** Directory the bridge persists its ship roster (`ships.json`) to. */ dataDirectory: z.string().min(1), - /** Port the bridge's HTTP + WebSocket API listens on. */ port: z.number().int(), - /** Human-facing name of this bridge. */ name: z.string().min(1), /** * URL *ships* use to reach this bridge — it is handed to each ship so it can @@ -28,10 +15,8 @@ export const BridgeConfigSchema = z.object({ publicUrl: z.string().min(1).optional(), }); -/** The bridge configuration, inferred from the schema. */ export type BridgeConfig = z.infer; -/** Where ships are told to reach a bridge that was not given a `publicUrl`. */ export function defaultPublicUrl(port: number): string { return `http://localhost:${port}`; } diff --git a/packages/fleet-bridge/src/fleet-manager.ts b/packages/fleet-bridge/src/fleet-manager.ts index cd3aba8..e8a07bb 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -1,17 +1,3 @@ -/** - * fleet-manager.ts — the framework-free core of the bridge. - * - * Owns every `ShipConnection`, the fleet-wide `/` → ship ownership - * index, duplicate enforcement, mutation routing, and roster persistence. It is - * deliberately free of any HTTP/Elysia concern so its dedupe and event-mutation - * logic can be unit-tested against fake connections. - * - * State model: each `ShipConnection` holds its own last-known `WorkspaceSummary` - * map (replaced wholesale on every `sync`); the manager derives a global - * `index: / → shipName` from those maps and consults it for O(1) - * routing and duplicate detection. - */ - import { join } from "node:path"; import { ARMORY_DIRECTORY, @@ -69,7 +55,6 @@ import { type ReviewEvent, } from "./providers"; -/** A typed error carrying the HTTP status the API layer should map it to. */ export class BridgeError extends Error { constructor( message: string, @@ -108,13 +93,9 @@ export class FleetManager { private readonly createReservations = new Map(); private readonly eventListeners = new Set<(event: BridgeWorkspaceEvent) => void>(); private readonly deps?: Partial; - /** How long to wait for a ship's first `sync` (overridable in tests). */ private readonly syncTimeoutMs: number; - /** Ship roster + repo registry persistence. Tests inject a shared `Store` via `opts.store`. */ private readonly store: Store; - /** Builds a `RepoProvider` for a registered repo; overridable in tests. */ private readonly makeProvider: (repo: Repo) => RepoProvider; - /** The bridge-owned file factory served from `/armory`. */ private readonly armory: ArmoryService; constructor( @@ -134,12 +115,7 @@ export class FleetManager { this.armory = opts?.armory ?? new ArmoryService(join(config.dataDirectory, ARMORY_DIRECTORY)); } - /** - * Load the persisted roster, connect to every ship, and enforce the no-duplicate - * rule across reachable ships. Throws (so the CLI can exit) if two reachable ships - * hold the same `/`. Unreachable ships start offline and reconnect in - * the background. - */ + /** Throws when two reachable ships hold the same `/`, so the CLI can exit. */ async init(): Promise { await this.store.load(); const records = await this.store.getAllShips(); @@ -150,7 +126,6 @@ export class FleetManager { conn.connect(); } - // Wait for reachable ships to send their first sync (or time out → offline). await Promise.all( [...this.connections.values()].map((conn) => conn.waitForSync(this.syncTimeoutMs).then( @@ -173,7 +148,6 @@ export class FleetManager { this.rebuildIndex(); } - /** Tear down every ship connection (used on shutdown / in tests). */ shutdown(): void { for (const conn of this.connections.values()) conn.close(); } @@ -200,8 +174,6 @@ export class FleetManager { this.publish({ type: "sync", at: new Date().toISOString(), workspaces: this.workspaceSnapshot() }); } - // --- ship management ------------------------------------------------------ - /** `GET /ships`. */ listShips(): ShipInfo[] { return [...this.connections.values()].map((conn) => ({ @@ -211,11 +183,7 @@ export class FleetManager { })); } - /** - * `POST /ships`. Connect to a ship by URL, learn its name and workspaces from - * its first `sync`, reject if the name is already registered or any of its - * workspaces collide fleet-wide, then adopt and persist it. - */ + /** `POST /ships`. */ async addShip(url: string): Promise { const probe = this.createConnection(url); probe.connect(); @@ -277,8 +245,6 @@ export class FleetManager { this.publishSnapshot(); } - // --- system resources ----------------------------------------------------- - /** `GET /ships/:ship/system-resources` — proxied live to one ship. */ async getShipSystemResources(shipName: string): Promise { const conn = this.connections.get(shipName); @@ -316,8 +282,6 @@ export class FleetManager { ); } - // --- repos (bridge-owned registry) ---------------------------------------- - /** `GET /repos` — the bridge's registered repos. */ async listRepos(): Promise { return this.store.getAllRepos(); @@ -347,10 +311,7 @@ export class FleetManager { if (!deleted) throw new BridgeError(`repo not found: ${name}`, 404); } - /** - * Look up a registered repo and run `fn` against its provider. `ProviderError` - * from `fn` propagates unchanged so the API can surface its HTTP status. - */ + /** `ProviderError` from `fn` propagates unchanged so the API can surface its status. */ private async withProvider(name: string, fn: (provider: RepoProvider) => Promise): Promise { this.identifier(name, "repo"); const repo = await this.store.getRepo(name); @@ -418,7 +379,6 @@ export class FleetManager { }); } - /** Resolve a check target to a commit-ish: a PR's head SHA, an explicit ref, or reject. */ private async resolveCheckRef( provider: RepoProvider, target: { ref?: string; pr?: number }, @@ -431,8 +391,6 @@ export class FleetManager { throw new BridgeError("checks require a ref or pr", 400); } - // --- armory (bridge-owned file factory) ----------------------------------- - /** `GET /armory` — the content-addressed manifest of `/armory`. */ async armoryManifest(): Promise { return this.mapArmoryErrors(() => this.armory.manifest()); @@ -469,17 +427,13 @@ export class FleetManager { ); } - /** Drop the cached scan — called when the armory directory changes on disk. */ invalidateArmory(): void { this.armory.invalidate(); } /** - * Tell every online ship to re-pull the armory. Never throws and never waits - * on one ship for another: a push is a notification, not a transaction, and a - * ship that is offline or that fails its pull must not break the bridge or - * hold up the rest of the fleet. Failures are warned about and dropped — - * whatever caused one will still be there at the next push or reconnect. + * Never throws and never waits on one ship for another: a push is a + * notification, not a transaction. Failures are warned about and dropped. */ async pushArmory(): Promise { const revision = await this.currentArmoryRevision(); @@ -490,7 +444,6 @@ export class FleetManager { await Promise.allSettled(online.map((conn) => this.syncArmoryOn(conn, revision))); } - /** The one-ship push, used when a ship joins the fleet or comes back online. */ private async pushArmoryTo(conn: ShipConnection): Promise { if (!conn.member || conn.status !== "online") return; const revision = await this.currentArmoryRevision(); @@ -536,8 +489,6 @@ export class FleetManager { } } - // --- workspace API (superset of the ship's) ------------------------------- - /** `GET /workspaces` — merged, deduped, annotated with the owning ship. */ async listWorkspaces(filter?: "active" | "inactive"): Promise { const snapshots = await Promise.all( @@ -737,8 +688,6 @@ export class FleetManager { return url.toString(); } - // --- internals ------------------------------------------------------------ - private createConnection(url: string, name?: string): ShipConnection { const conn = new ShipConnection({ url, name, deps: this.deps }); conn.setHandlers({ @@ -752,7 +701,6 @@ export class FleetManager { return conn; } - /** Apply an event to the ownership index — only for adopted (member) connections. */ private onEvent(conn: ShipConnection, event: FleetEvent): void { if (!conn.member) return; this.applyToIndex(conn, event); @@ -860,7 +808,6 @@ export class FleetManager { return duplicates; } - /** Rebuild the ownership index from every online connection's workspace map. */ private rebuildIndex(): void { this.index.clear(); for (const conn of this.connections.values()) { diff --git a/packages/fleet-bridge/src/index.ts b/packages/fleet-bridge/src/index.ts index d60763e..48e9f6d 100755 --- a/packages/fleet-bridge/src/index.ts +++ b/packages/fleet-bridge/src/index.ts @@ -10,7 +10,6 @@ import { createApp } from "./api"; export type { BridgeConfig } from "./config"; -/** Default port the bridge's HTTP + WebSocket API listens on. */ export const DEFAULT_BRIDGE_PORT = 4800; function parsePort(value: string): number { @@ -19,12 +18,7 @@ function parsePort(value: string): number { return port; } -/** - * Bring up a bridge: init the manager (loads the persisted ship roster and - * connects to each ship), watch the armory, then serve the API. Returns the - * manager so callers (e.g. `fleet launch`) can register additional ships, and - * the armory watcher so they can close it. Throws on failure. - */ +/** Returns the manager so callers (e.g. `fleet launch`) can register more ships, and the watcher so they can close it. */ export async function startBridge( config: BridgeConfig, ): Promise<{ manager: FleetManager; watcher: ArmoryWatcher }> { diff --git a/packages/fleet-bridge/src/providers/github.ts b/packages/fleet-bridge/src/providers/github.ts index f4ed2ae..2521ef2 100644 --- a/packages/fleet-bridge/src/providers/github.ts +++ b/packages/fleet-bridge/src/providers/github.ts @@ -1,14 +1,3 @@ -/** - * providers/github.ts — a `RepoProvider` backed by GitHub's REST API v3. - * - * Every dependency (owner/repo, token, `fetch`, base URL) is injected through - * the constructor so the provider is exercisable in unit tests without env vars - * or the network. GitHub's `/issues` endpoint also returns pull requests, so - * `listIssues` filters out any element carrying a `pull_request` key to keep the - * two streams distinct. Write operations require a token — GitHub cannot accept - * anonymous comments/reviews — so those methods reject early with a 401. - */ - import type { CheckRun, FailedJobLog, @@ -174,6 +163,7 @@ export class GitHubProvider implements RepoProvider { const issues = await this.request( `/repos/${this.owner}/${this.repo}/issues?state=${state}`, ); + // GitHub's `/issues` endpoint returns pull requests too. return issues .filter((issue) => issue.pull_request === undefined) .map((issue) => this.toIssueSummary(issue)); diff --git a/packages/fleet-bridge/src/providers/index.ts b/packages/fleet-bridge/src/providers/index.ts index ec37291..507b22f 100644 --- a/packages/fleet-bridge/src/providers/index.ts +++ b/packages/fleet-bridge/src/providers/index.ts @@ -1,13 +1,3 @@ -/** - * providers/index.ts — the entry point that builds a `RepoProvider` for a repo. - * - * `providerFor` switches on the repo's `provider` string and resolves a token - * from explicit deps, then the environment (GITHUB_TOKEN, then GH_TOKEN). Only - * GitHub is wired today; other providers throw a 501 so the API can report that - * the forge is recognized but not yet implemented. The public provider surface - * is re-exported here so consumers import everything from one place. - */ - import type { Repo } from "fleet-protocol"; import { GitHubProvider, parseGitHubRepo } from "./github"; import { ProviderError, type RepoProvider } from "./provider"; diff --git a/packages/fleet-bridge/src/providers/provider.ts b/packages/fleet-bridge/src/providers/provider.ts index 15d76eb..81028f3 100644 --- a/packages/fleet-bridge/src/providers/provider.ts +++ b/packages/fleet-bridge/src/providers/provider.ts @@ -1,17 +1,3 @@ -/** - * providers/provider.ts — the framework-free `RepoProvider` abstraction. - * - * The bridge only knows GitHub today, but a repo's `provider` field already - * admits "gitlab"/"custom". This module defines a host-agnostic interface and a - * clean set of DTOs so the rest of the bridge can query issues/PRs without - * touching any single forge's REST shapes. Like `types.ts`, these result types - * are plain TypeScript (consumed through Elysia/Eden inference), not zod schemas. - * - * `ProviderError` deliberately mirrors — but does not import — the manager's - * `BridgeError`: the provider layer stays free of the HTTP framework, and a - * later step maps its `status` onto the API's error responses. - */ - /** An HTTP-shaped provider failure; `status` is the status a route should surface. */ export class ProviderError extends Error { readonly status: number; @@ -76,7 +62,6 @@ export interface CheckRun { readonly completedAt: string | null; } -/** The raw log of one failed GitHub Actions job, tagged with its workflow/job. */ export interface FailedJobLog { readonly workflow: string; readonly job: string; diff --git a/packages/fleet-bridge/src/ship-connection.ts b/packages/fleet-bridge/src/ship-connection.ts index 411a60b..fb81631 100644 --- a/packages/fleet-bridge/src/ship-connection.ts +++ b/packages/fleet-bridge/src/ship-connection.ts @@ -1,24 +1,8 @@ -/** - * ship-connection.ts — one live connection to a single ship. - * - * A `ShipConnection` owns everything transport-related for one ship: - * - an Eden Treaty client (`treaty`) for command/control HTTP calls, - * - a raw `/events` WebSocket that keeps `workspaces` (this ship's last-known - * `WorkspaceSummary` map) in sync, decoded via `decodeFleetEvent`, - * - `online`/`offline` status plus a reconnect loop with exponential backoff. - * - * It applies each decoded event to its own `workspaces` map and forwards it to - * the `FleetManager` (which maintains the fleet-wide ownership index). The - * WebSocket and Eden client are created through injectable factories so the - * manager's dedupe/routing logic is unit-testable against fakes. - */ - import { treaty } from "@elysiajs/eden"; import type { App as ShipApp } from "fleet-ship/api"; import { decodeFleetEvent, type FleetEvent, type SyncEvent, type WorkspaceSummary } from "fleet-protocol"; import { workspaceKey, type ShipStatus } from "./types"; -/** The Eden Treaty client the bridge uses to drive a ship. */ export type ShipClient = ReturnType>; /** A minimal WebSocket surface — the browser/Bun `WebSocket` satisfies it. */ @@ -30,13 +14,11 @@ export interface SocketLike { close(): void; } -/** Injectable factories (overridden in tests). */ export interface ShipConnectionDeps { createSocket: (url: string) => SocketLike; createClient: (url: string) => ShipClient; } -/** Callbacks the manager registers to observe a connection. */ export interface ShipConnectionHandlers { onEvent: (conn: ShipConnection, event: FleetEvent) => void; onStatusChange: (conn: ShipConnection, status: ShipStatus) => void; @@ -49,7 +31,6 @@ const defaultDeps: ShipConnectionDeps = { const MAX_BACKOFF_MS = 30_000; -/** Turn a ship's base HTTP url into a ws(s):// url for `path`. */ export function toWsUrl(httpUrl: string, path: string): string { const u = new URL(httpUrl); u.protocol = u.protocol === "https:" ? "wss:" : "ws:"; @@ -63,7 +44,6 @@ export class ShipConnection { readonly url: string; /** Whether a `/events` socket is currently open. */ status: ShipStatus = "offline"; - /** Eden client for command/control. */ readonly client: ShipClient; /** This ship's last-known workspaces, keyed by `/`. */ readonly workspaces = new Map(); @@ -87,7 +67,6 @@ export class ShipConnection { this.client = this.deps.createClient(opts.url); } - /** Register the manager's observers. */ setHandlers(handlers: ShipConnectionHandlers): void { this.handlers = handlers; } @@ -113,7 +92,6 @@ export class ShipConnection { }); } - /** Force the connection offline (e.g. after a failed command/control call). */ markOffline(): void { if (this.status !== "offline") this.setStatus("offline"); } @@ -145,9 +123,7 @@ export class ShipConnection { socket.onerror = () => { try { socket.close(); - } catch { - // ignore - } + } catch {} }; } @@ -156,7 +132,7 @@ export class ShipConnection { try { event = decodeFleetEvent(typeof data === "string" ? data : String(data)); } catch { - return; // ignore anything that isn't a valid FleetEvent + return; } if (this.identity === undefined) { diff --git a/packages/fleet-bridge/src/store/store.ts b/packages/fleet-bridge/src/store/store.ts index 2bf4bd8..64bc364 100644 --- a/packages/fleet-bridge/src/store/store.ts +++ b/packages/fleet-bridge/src/store/store.ts @@ -1,5 +1,3 @@ -/** The bridge's serialized, atomic JSON-file persistence. */ - import { lstat, open, rename, unlink } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { FleetIdentifierSchema, RepoSchema, ShipSchema, type Repo, type Ship } from "fleet-protocol"; diff --git a/packages/fleet-bridge/src/types.ts b/packages/fleet-bridge/src/types.ts index 94e4f8c..aa05986 100644 --- a/packages/fleet-bridge/src/types.ts +++ b/packages/fleet-bridge/src/types.ts @@ -1,13 +1,3 @@ -/** - * types.ts — bridge-local response DTOs and helpers. - * - * The bridge exposes a superset of the ship workspace API with the owning ship - * made visible on every workspace. These shapes are only ever consumed through - * Elysia's own type inference (Eden), so — unlike `fleet-protocol`'s - * `WorkspaceSummary` / event union, which a third party decodes at runtime — - * they are plain types, not zod schemas. - */ - import type { ArmorySyncState, SystemResources, WorkspaceStatus, WorkspaceSummary } from "fleet-protocol"; /** Whether the bridge currently has a live `/events` connection to a ship. */ @@ -20,10 +10,8 @@ export interface ShipInfo { readonly status: ShipStatus; } -/** `WorkspaceSummary` annotated with the ship that hosts it (list rows). */ export type BridgeWorkspaceSummary = WorkspaceSummary & { ship: string }; -/** `WorkspaceStatus` with `ship` guaranteed present on both variants. */ export type BridgeWorkspaceStatus = WorkspaceStatus & { ship: string }; export type BridgeWorkspaceEvent = diff --git a/packages/fleet-bridge/tests/api.test.ts b/packages/fleet-bridge/tests/api.test.ts index c95e7ac..529691c 100644 --- a/packages/fleet-bridge/tests/api.test.ts +++ b/packages/fleet-bridge/tests/api.test.ts @@ -1,9 +1,3 @@ -/** - * api.test.ts — drives the bridge's composed Elysia app in-process via - * `app.handle(Request)` (no port) over a real FleetManager + fake ships, asserting - * the HTTP status codes and bodies the routes actually return. - */ - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -143,11 +137,9 @@ describe("bridge API", () => { expect(created.status).toBe(201); expect(created.body).toMatchObject({ repoName: "repo3", name: "three", ship: "ship-a" }); - // Unknown ship. expect( (await call("POST", "/workspaces", { ship: "ghost", repoName: "repo3", name: "n", branch: "main" })).status, ).toBe(400); - // Unregistered repo. expect( (await call("POST", "/workspaces", { ship: "ship-a", repoName: "ghost-repo", name: "n", branch: "main" })) .status, @@ -156,7 +148,6 @@ describe("bridge API", () => { expect( (await call("POST", "/workspaces", { ship: "ship-a", repoName: "repo1", name: "one", branch: "main" })).status, ).toBe(409); - // Missing `ship`. expect((await call("POST", "/workspaces", { repoName: "repo3", name: "n", branch: "main" })).status).toBe(422); }); @@ -208,9 +199,7 @@ describe("bridge API", () => { expect(created.body).toEqual({ name: "repo1", url: "git@fake/repo1.git", provider: "custom" }); expect((await call("POST", "/repos", { name: "repo2", url: "u", provider: "github" })).status).toBe(201); - // Duplicate name. expect((await call("POST", "/repos", { name: "repo1", url: "u" })).status).toBe(409); - // Missing url. expect((await call("POST", "/repos", { name: "x" })).status).toBe(422); const list = await call("GET", "/repos"); diff --git a/packages/fleet-bridge/tests/armory-push.test.ts b/packages/fleet-bridge/tests/armory-push.test.ts index 99d6f0c..92ae978 100644 --- a/packages/fleet-bridge/tests/armory-push.test.ts +++ b/packages/fleet-bridge/tests/armory-push.test.ts @@ -1,12 +1,3 @@ -/** - * armory-push.test.ts — the write side of the armory: the watcher that notices a - * change and the `FleetManager` push that tells each ship to re-pull. - * - * The watcher is driven through an injected `fs.watch` so the debounce is tested - * against timers rather than real filesystem event timing; the push runs against - * the shared fake ships, whose Eden client records what it was asked to sync. - */ - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import type { FSWatcher, WatchListener } from "node:fs"; diff --git a/packages/fleet-bridge/tests/armory-ships.test.ts b/packages/fleet-bridge/tests/armory-ships.test.ts index cf8c131..cffc949 100644 --- a/packages/fleet-bridge/tests/armory-ships.test.ts +++ b/packages/fleet-bridge/tests/armory-ships.test.ts @@ -1,12 +1,3 @@ -/** - * armory-ships.test.ts — the aggregate `GET /armory/ships`, which reports what - * every member ship has pulled and installed. - * - * The point of the aggregate is that it degrades per ship rather than as a - * whole, so the cases here are a healthy ship, an offline one, and one whose - * call fails — all in the same response. - */ - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/fleet-bridge/tests/armory.test.ts b/packages/fleet-bridge/tests/armory.test.ts index a0b1abd..726f54e 100644 --- a/packages/fleet-bridge/tests/armory.test.ts +++ b/packages/fleet-bridge/tests/armory.test.ts @@ -1,9 +1,3 @@ -/** - * armory.test.ts — exercises `ArmoryService` against real temp directories (the - * scan is all filesystem behaviour, so there is nothing worth faking) plus the - * `/armory` routes through the composed Elysia app. - */ - import { afterEach, describe, expect, test } from "bun:test"; import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/fleet-bridge/tests/fleet-manager.test.ts b/packages/fleet-bridge/tests/fleet-manager.test.ts index e6f0414..4ec08bd 100644 --- a/packages/fleet-bridge/tests/fleet-manager.test.ts +++ b/packages/fleet-bridge/tests/fleet-manager.test.ts @@ -170,7 +170,6 @@ describe("FleetManager", () => { expect(info).toMatchObject({ name: "ship-b", url: "http://ship-b", status: "online" }); expect((await mgr.listWorkspaces()).map((w) => w.ship).sort()).toEqual(["ship-a", "ship-b"]); - // Persisted to the roster store. const persisted = await store.getAllShips(); expect(persisted.map((s) => s.name).sort()).toEqual(["ship-a", "ship-b"]); }); @@ -218,12 +217,10 @@ describe("FleetManager", () => { mgr.createWorkspace({ ship: "ghost", repoName: "repo1", name: "n", branch: "main" }), ).rejects.toMatchObject({ status: 400 }); - // Unregistered repo. await expect( mgr.createWorkspace({ ship: "ship-a", repoName: "ghost-repo", name: "n", branch: "main" }), ).rejects.toMatchObject({ status: 400 }); - // Duplicate workspace. await expect( mgr.createWorkspace({ ship: "ship-a", repoName: "repo1", name: "one", branch: "main" }), ).rejects.toMatchObject({ status: 409 }); @@ -749,8 +746,6 @@ describe("FleetManager", () => { expect(BridgeError).toBeDefined(); }); - // --- runtime event application -------------------------------------------- - const evt = (type: string, ship: string, w: ReturnType) => ({ type, ship, @@ -854,8 +849,6 @@ describe("FleetManager", () => { expect((await mgr.listWorkspaces()).find((row) => row.name === "one")?.ship).toBe("online-z"); }); - // --- verb happy paths + error/offline translation ------------------------- - test("switchBranch / deactivate / remove reach the owning ship", async () => { const ships = new Map([ ["http://ship-a", { name: "ship-a", workspaces: [ws("repo1", "one", true)] }], @@ -892,8 +885,6 @@ describe("FleetManager", () => { expect(mgr.listShips()[0]?.status).toBe("offline"); }); - // --- add / startup timeout ------------------------------------------------ - test("addShip returns 502 when the ship never syncs", async () => { const ships = new Map([ ["http://ship-a", { name: "ship-a", workspaces: [] }], diff --git a/packages/fleet-bridge/tests/helpers.ts b/packages/fleet-bridge/tests/helpers.ts index 95f26df..f410502 100644 --- a/packages/fleet-bridge/tests/helpers.ts +++ b/packages/fleet-bridge/tests/helpers.ts @@ -1,13 +1,3 @@ -/** - * helpers.ts — shared fakes for the bridge test suite. - * - * A `FleetManager` is built against a fake `SocketLike` (`/events`) and a fake - * Eden client, so the dedupe/routing/error logic is exercised with no real ships. - * `FakeSocket.byBase` lets a test grab a ship's live socket to close it (force - * offline) or `emit()` a post-init event; a ship can also be configured to never - * sync, to return an Eden error, or to throw (network failure). - */ - import type { ArmorySyncState, FleetEvent, SystemResources, WorkspaceSummary } from "fleet-protocol"; import type { ShipConnectionDeps, SocketLike } from "../src/ship-connection"; @@ -33,7 +23,6 @@ export interface FakeShip { throws?: boolean; } -/** Reduce a `ws://host/events` (or `/…/terminal`) url back to its `http://host` base. */ export function httpBase(wsUrl: string): string { const u = new URL(wsUrl); u.protocol = u.protocol === "wss:" ? "https:" : "http:"; @@ -73,7 +62,6 @@ export class FakeSocket implements SocketLike { }, 0); } - /** Push a `/events` message to the connection (drives post-init event tests). */ emit(event: FleetEvent | Record): void { this.onmessage?.({ data: JSON.stringify(event) }); } @@ -102,7 +90,6 @@ export function fakeResources(hostname: string): SystemResources { }; } -/** A fake Eden client covering the ship endpoints the manager calls. */ export function makeFakeClient(httpUrl: string, ships: Map) { const ship = () => ships.get(httpUrl); @@ -226,7 +213,6 @@ export function makeFakeClient(httpUrl: string, ships: Map) { }; } -/** Build `ShipConnectionDeps` backed by the fake ships (optionally overriding pieces). */ export function makeDeps( ships: Map, overrides?: Partial, @@ -238,7 +224,6 @@ export function makeDeps( }; } -/** Convenience `WorkspaceSummary` builder. */ export const ws = (repoName: string, name: string, active = false): WorkspaceSummary => ({ repoName, name, diff --git a/packages/fleet-bridge/tests/repo-provider-api.test.ts b/packages/fleet-bridge/tests/repo-provider-api.test.ts index 57b5110..fb9fcbd 100644 --- a/packages/fleet-bridge/tests/repo-provider-api.test.ts +++ b/packages/fleet-bridge/tests/repo-provider-api.test.ts @@ -1,9 +1,3 @@ -/** - * repo-provider-api.test.ts — drives the composed bridge app in-process (like - * api.test.ts) but injects a FAKE provider factory so the `/repos/:name/...` - * routes exercise the FleetManager → provider seam with no network. - */ - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/fleet-bridge/tests/ship-connection.test.ts b/packages/fleet-bridge/tests/ship-connection.test.ts index 4be4e1f..452d9f5 100644 --- a/packages/fleet-bridge/tests/ship-connection.test.ts +++ b/packages/fleet-bridge/tests/ship-connection.test.ts @@ -1,10 +1,3 @@ -/** - * ship-connection.test.ts — direct unit tests for the transport layer: - * `toWsUrl`, `waitForSync` (resolve + timeout), event application to the - * connection's own workspace map, and status transitions. A hand-driven - * `ManualSocket` lets each test control open/message/close timing. - */ - import { afterEach, describe, expect, test } from "bun:test"; import { ShipConnection, toWsUrl, type SocketLike } from "../src/ship-connection"; import type { ShipStatus } from "../src/types"; diff --git a/packages/fleet-bridge/tests/terminal-proxy.test.ts b/packages/fleet-bridge/tests/terminal-proxy.test.ts index d9f8eb3..558b210 100644 --- a/packages/fleet-bridge/tests/terminal-proxy.test.ts +++ b/packages/fleet-bridge/tests/terminal-proxy.test.ts @@ -1,11 +1,3 @@ -/** - * terminal-proxy.test.ts — exercises the bridge's `/workspaces/:repo/:name/terminal` - * WebSocket proxy end-to-end against a real (stub) upstream ship. Both the bridge - * and the stub listen on ephemeral ports; a real browser-style `WebSocket` drives - * the bridge. Covers: bidirectional forwarding (incl. buffered-before-open frames), - * upstream-close propagation, and the unknown-workspace exit path. - */ - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/fleet-client/src/components/DiffView.tsx b/packages/fleet-client/src/components/DiffView.tsx index 872f9d1..57ba72b 100644 --- a/packages/fleet-client/src/components/DiffView.tsx +++ b/packages/fleet-client/src/components/DiffView.tsx @@ -90,7 +90,6 @@ export function DiffView({ repo, name }: DiffViewProps) { if (cancelled) return; const parsed = parseDiff(raw); setFiles(parsed); - // Keep the selection if it still exists, otherwise select the first file. setSelectedId((prev) => (parsed.some((f) => f.id === prev) ? prev : (parsed[0]?.id ?? null))); } catch (e) { if (!cancelled) setError((e as Error).message); diff --git a/packages/fleet-client/src/components/TerminalGrid.tsx b/packages/fleet-client/src/components/TerminalGrid.tsx index 0fe862e..a4a3a9d 100644 --- a/packages/fleet-client/src/components/TerminalGrid.tsx +++ b/packages/fleet-client/src/components/TerminalGrid.tsx @@ -17,7 +17,6 @@ interface CellMetrics { height: number; } -/** Terminal default colors, read once from the fixed `--color-term-*` palette. */ interface TermColors { fg: string; bg: string; @@ -39,25 +38,20 @@ function baseFont(bold: boolean, italic: boolean): string { } /** - * Whether a delegated React event was aimed at the handler's own element rather - * than bubbled up from a descendant — the takeover button. Only keys aimed at - * the container itself belong to the PTY: without this guard, Enter/Space on - * that button would be swallowed (`encodeKeyEvent` maps them, and the - * `preventDefault` cancels the button's synthesized click), stranding - * keyboard-only users on the conflict overlay. + * Only keys aimed at the container itself belong to the PTY: without this guard, + * Enter/Space on the takeover button would be swallowed (`encodeKeyEvent` maps + * them, and the `preventDefault` cancels the button's synthesized click). */ function ownEvent(e: { target: EventTarget; currentTarget: EventTarget }): boolean { return e.target === e.currentTarget; } -/** How the cursor cell should be painted this frame. */ export type CursorRender = "hidden" | "solid" | "outline"; /** - * Whether the cursor's phase is currently animating. The single source of truth - * for the blink condition: both the renderer and the blink timer ask this, so a - * timer tick can never disagree with what the next frame would paint. - * `blinking` is optional on the wire and defaults to on. + * The single source of truth for the blink condition: both the renderer and the + * blink timer ask this, so a timer tick can never disagree with what the next + * frame would paint. `blinking` is optional on the wire and defaults to on. */ export function cursorBlinks(cursor: GridMsg["cursor"], focused: boolean): boolean { return cursor.visible && focused && (cursor.blinking ?? true); @@ -80,7 +74,7 @@ export function cursorRender( return cursorOn ? "solid" : "hidden"; } -/** Paint a full grid snapshot. The context transform already accounts for DPR. */ +/** The context transform already accounts for DPR. */ function drawGrid( ctx: CanvasRenderingContext2D, grid: GridMsg, @@ -237,11 +231,10 @@ function line(ctx: CanvasRenderingContext2D, x: number, y: number, w: number) { } /** - * A live terminal painted on a canvas. Grid snapshots arrive at up to 60fps, so - * they bypass React state entirely: the newest frame lives in a ref and is drawn - * on the next animation frame (multiple frames between paints coalesce, which is - * lossless since each `GridMsg` is a full snapshot). Only the rare status/exit - * transitions use React state. + * Grid snapshots arrive at up to 60fps, so they bypass React state entirely: the + * newest frame lives in a ref and is drawn on the next animation frame (multiple + * frames between paints coalesce, which is lossless since each `GridMsg` is a + * full snapshot). Only the rare status/exit transitions use React state. */ export function TerminalGrid({ repo, name, active }: { repo: string; name: string; active: boolean }) { const containerRef = useRef(null); @@ -294,7 +287,6 @@ export function TerminalGrid({ repo, name, active }: { repo: string; name: strin onExit: (code) => setExitCode(code), }); - // Reset transient session state whenever we (re)attach. useEffect(() => { if (active) { setExitCode(null); @@ -334,7 +326,6 @@ export function TerminalGrid({ repo, name, active }: { repo: string; name: strin }; }, [active, scheduleDraw]); - // Size the canvas to the container, tell the PTY, and repaint on any change. useEffect(() => { const container = containerRef.current; const canvas = canvasRef.current; @@ -378,7 +369,6 @@ export function TerminalGrid({ repo, name, active }: { repo: string; name: strin return () => observer.disconnect(); }, [active, resize, scheduleDraw]); - // Blink the cursor independently of terminal output. useEffect(() => { if (!active) return; const id = setInterval(() => { @@ -456,7 +446,6 @@ export function TerminalGrid({ repo, name, active }: { repo: string; name: strin className="relative min-h-0 flex-1 cursor-text overflow-hidden bg-term-bg px-3 py-2 outline-none focus:ring-1 focus:ring-inset focus:ring-term-line" > - {/* The conflict overlay owns pointer events — its button is the only way out. */} {status === "conflict" && exitCode === null ? (
diff --git a/packages/fleet-client/src/components/WorkspaceNode.tsx b/packages/fleet-client/src/components/WorkspaceNode.tsx index cc91b76..9c499c9 100644 --- a/packages/fleet-client/src/components/WorkspaceNode.tsx +++ b/packages/fleet-client/src/components/WorkspaceNode.tsx @@ -2,10 +2,6 @@ import { Link } from "react-router-dom"; import type { Workspace } from "@/data/types"; import { agentStateColor } from "@/lib/agent-status"; -/** - * A workspace tile in the Bridge grid. The outline/active fill and radius come - * from the `--node-*` tokens (a border shorthand, so applied via inline style). - */ export function WorkspaceNode({ ws }: { ws: Workspace }) { const color = ws.agent ? agentStateColor(ws.agent.state) : ws.active ? "var(--dim)" : "var(--line)"; diff --git a/packages/fleet-client/src/components/WorkspacePanel.tsx b/packages/fleet-client/src/components/WorkspacePanel.tsx index 9a50a79..8e421a4 100644 --- a/packages/fleet-client/src/components/WorkspacePanel.tsx +++ b/packages/fleet-client/src/components/WorkspacePanel.tsx @@ -15,9 +15,9 @@ interface WorkspacePanelProps { type Tab = "terminal" | "diff"; /** - * The workspace's main pane: a Terminal/Diff tab switcher. Each tab is mounted - * only while selected — the terminal re-attaches its (server-persistent tmux) - * session on switch-back, and the diff is fetched fresh when its tab opens. + * Each tab is mounted only while selected — the terminal re-attaches its + * (server-persistent tmux) session on switch-back, and the diff is fetched fresh + * when its tab opens. */ export function WorkspacePanel({ repo, name, ship, branch, active, onActivate }: WorkspacePanelProps) { const [tab, setTab] = useState("terminal"); diff --git a/packages/fleet-client/src/components/ui/modal.tsx b/packages/fleet-client/src/components/ui/modal.tsx index dae275c..35f99e6 100644 --- a/packages/fleet-client/src/components/ui/modal.tsx +++ b/packages/fleet-client/src/components/ui/modal.tsx @@ -9,9 +9,8 @@ interface ModalProps { } /** - * Minimal centered dialog in the Bridge design language. Closes on backdrop click - * and Esc. Not focus-trapped — the app has no other overlay competing for focus, - * so a full a11y dialog primitive would be more machinery than this UI needs. + * Not focus-trapped — the app has no other overlay competing for focus, so a + * full a11y dialog primitive would be more machinery than this UI needs. */ export function Modal({ open, onClose, title, children }: ModalProps) { useEffect(() => { diff --git a/packages/fleet-client/src/data/FleetContext.tsx b/packages/fleet-client/src/data/FleetContext.tsx index f759937..0963ac7 100644 --- a/packages/fleet-client/src/data/FleetContext.tsx +++ b/packages/fleet-client/src/data/FleetContext.tsx @@ -18,9 +18,7 @@ interface FleetValue { repos: Repo[]; workspaces: Workspace[]; loading: boolean; - /** Set when talking to the bridge fails (e.g. it is unreachable). */ error: string | null; - /** Number of active workspaces across the fleet (drives "N sessions live"). */ liveCount: number; activate: (repo: string, name: string) => Promise; deactivate: (repo: string, name: string) => Promise; @@ -29,9 +27,7 @@ interface FleetValue { /** Delete a workspace, then refresh the workspace list. Rejects on failure. */ deleteWorkspace: (repo: string, name: string) => Promise; getWorkspace: (repo: string, name: string) => Promise; - /** Raw `git diff` text for a workspace, narrowed by the caller's diff query. */ getWorkspaceDiff: (repo: string, name: string, query: DiffQuery) => Promise; - /** Branches and recent commits a workspace's diff can be taken against. */ getWorkspaceRefs: (repo: string, name: string) => Promise; /** Create a workspace, then refresh the workspace list. Rejects on failure. */ createWorkspace: (input: { ship: string; repoName: string; name: string; branch: string }) => Promise; @@ -43,20 +39,17 @@ interface FleetValue { createShip: (url: string) => Promise; /** Deregister a ship, then refresh the ship list. Rejects on failure. */ deleteShip: (name: string) => Promise; - /** The bridge's armory manifest. Fetched on demand — the armory is not part of the boot snapshot. */ getArmory: () => Promise; - /** One armory file's contents. */ getArmoryFile: (path: string) => Promise; - /** What each ship has pulled and installed from the armory. */ listArmoryShips: () => Promise; } const FleetContext = createContext(null); /** - * Loads the fleet snapshot once and shares it with every view. Mutations refresh - * the workspace list from the bridge, so all derived indicators — grid dots, - * repo ACTIVE counts, sibling dots, the sidebar live counter — update together. + * Mutations refresh the workspace list from the bridge, so all derived + * indicators — grid dots, repo ACTIVE counts, sibling dots, the sidebar live + * counter — update together. */ export function FleetProvider({ children }: { children: ReactNode }) { const [ships, setShips] = useState([]); diff --git a/packages/fleet-client/src/data/eden.ts b/packages/fleet-client/src/data/eden.ts index d1050c6..6c9754f 100644 --- a/packages/fleet-client/src/data/eden.ts +++ b/packages/fleet-client/src/data/eden.ts @@ -57,11 +57,6 @@ function deriveSpec(r: SystemResources | null): string { return `${r.cpu.cores} cores · ${gb} GB · ${r.os.arch}`; } -/** - * Real {@link FleetBridge} backed by an Eden treaty against the fleet bridge. - * The live terminal is a WebSocket stream, handled separately by the Terminal - * component (see `useWebterm`), not through this request/response surface. - */ export class EdenFleetBridge implements FleetBridge { constructor( private readonly client: BridgeClient = makeBridgeClient(), diff --git a/packages/fleet-client/src/data/mock.ts b/packages/fleet-client/src/data/mock.ts index dbbbc1e..ac41bf7 100644 --- a/packages/fleet-client/src/data/mock.ts +++ b/packages/fleet-client/src/data/mock.ts @@ -14,13 +14,6 @@ import type { WorkspaceEvent, } from "./types"; -/** - * In-memory implementation of {@link FleetBridge}. Seed data is ported from the - * design prototype (`support.js`); the `active` flags are mutable so - * activate/deactivate persist for the session. The live terminal is not mocked — - * it streams over a real WebSocket (see the Terminal component's `useWebterm`). - */ - const SHIPS: Ship[] = [ { name: "forge-01", spec: "2×A100 · us-east-1", status: "online" }, { name: "forge-02", spec: "2×A100 · us-east-1", status: "online" }, @@ -53,14 +46,12 @@ function key(repo: string, name: string): string { return `${repo}/${name}`; } -/** Deterministic pseudo-pid from a workspace name (matches the prototype hash). */ function hashPid(id: string): number { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0; return 10000 + (Math.abs(h) % 89999); } -/** Deterministic mock working-tree diff for an active workspace. */ function mockDiff(name: string): WorkspaceDiff { const h = Math.abs(hashPid(name)); return { added: 8 + (h % 40), removed: h % 15, commits: 1 + (h % 3) }; @@ -335,7 +326,6 @@ const SEED_ARMORY_SHIP_STATES: Record = { }, }; -/** Seed the repo registry from the distinct repo names in the seed workspaces. */ function seedRepos(): Repo[] { const names: string[] = []; for (const w of SEED_WORKSPACES) { diff --git a/packages/fleet-client/src/data/provider.ts b/packages/fleet-client/src/data/provider.ts index e628c3d..257c1ca 100644 --- a/packages/fleet-client/src/data/provider.ts +++ b/packages/fleet-client/src/data/provider.ts @@ -11,21 +11,6 @@ import type { WorkspaceEvent, } from "./types"; -/** - * The data our UI needs from the fleet bridge, expressed as one async surface. - * - * Every method maps 1:1 to a bridge route. The real implementation would be a - * thin wrapper over an Eden treaty client: - * - * import { treaty } from "@elysiajs/eden"; - * import type { App } from "fleet-bridge/api"; - * const client = treaty(bridgeUrl); - * // listWorkspaces() -> client.workspaces.get() -> { data, error } - * - * `MockFleetBridge` (see ./mock) implements this against in-memory fixtures so - * the whole app runs with no bridge attached. Swapping in the Eden-backed - * implementation is the only change needed to go live. - */ export interface FleetBridge { /** `GET /ships` (joined with `GET /system-resources` for the spec blurb). */ listShips(): Promise; diff --git a/packages/fleet-client/src/data/types.ts b/packages/fleet-client/src/data/types.ts index 9161710..aa88189 100644 --- a/packages/fleet-client/src/data/types.ts +++ b/packages/fleet-client/src/data/types.ts @@ -1,10 +1,6 @@ /** - * View-model types for the Bridge UI. - * - * These reuse the real fleet contract from `fleet-protocol` wherever possible. * The bridge-local shapes (`ShipInfo`, the ship-annotated workspace DTOs) are not - * exported from `fleet-bridge`, so they are mirrored here — a real client would - * instead read them straight off `treaty`'s inferred types. + * exported from `fleet-bridge`, so they are mirrored here. */ import type { ArmorySyncState, WorkspaceSummary, WorkspaceStatus } from "fleet-protocol"; @@ -22,17 +18,13 @@ export type { /** Whether the bridge currently has a live connection to a ship. */ export type ShipStatus = "online" | "offline"; -/** - * A ship (host). `spec` is the human-facing hardware/region blurb the bridge - * would derive from the ship's `SystemResources` (e.g. "2×A100 · us-east-1"). - */ +/** `spec` is a human-facing hardware/region blurb (e.g. "2×A100 · us-east-1"). */ export interface Ship { readonly name: string; readonly spec: string; readonly status: ShipStatus; } -/** List row: a `WorkspaceSummary` annotated with its hosting ship. */ export type Workspace = WorkspaceSummary & { readonly ship: string }; export type WorkspaceEvent = @@ -53,8 +45,8 @@ export type WorkspaceEvent = export type WorkspaceDetail = WorkspaceStatus & { readonly ship: string }; /** - * A row of `GET /armory/ships`: what one ship has pulled and installed. `state` - * is null when the bridge could not ask the ship — offline, or the call failed. + * A row of `GET /armory/ships`. `state` is null when the bridge could not ask + * the ship — offline, or the call failed. */ export interface ArmoryShipState { readonly ship: string; diff --git a/packages/fleet-client/src/data/useWebterm.ts b/packages/fleet-client/src/data/useWebterm.ts index e234112..0084832 100644 --- a/packages/fleet-client/src/data/useWebterm.ts +++ b/packages/fleet-client/src/data/useWebterm.ts @@ -59,7 +59,6 @@ export function handleServerFrame( } } -/** Status a closed socket leaves behind, derived from the ship's close code. */ export function closeStatus(code: number): WebtermStatus { if (code === TERMINAL_CONFLICT_CLOSE_CODE) return "conflict"; if (code === TERMINAL_TAKEOVER_CLOSE_CODE) return "superseded"; @@ -73,7 +72,6 @@ export function terminalPath(repo: string, name: string, takeover = false): stri interface UseWebtermResult { status: WebtermStatus; - /** Write keystroke/paste bytes to the PTY. */ send: (data: string) => void; /** * Reconnect, evicting whichever connection currently owns the workspace's @@ -89,9 +87,8 @@ interface UseWebtermResult { } /** - * Connect to a workspace's live terminal over the webterm grid protocol. Opens - * the WebSocket only while `active`, tearing it down (and releasing the ship's - * single-terminal guard) when `active` goes false or the component unmounts. + * Opens the WebSocket only while `active`, tearing it down (and releasing the + * ship's single-terminal guard) when `active` goes false or on unmount. */ export function useWebterm( repo: string, @@ -124,7 +121,6 @@ export function useWebterm( const optsRef = useRef(opts); optsRef.current = opts; - /** Send `init` the first time, `resize` thereafter. */ const sendSize = useCallback((ws: WebSocket, cols: number, rows: number) => { ({ cols, rows } = clampTerminalSize(cols, rows)); const type = initializedRef.current ? "resize" : "init"; diff --git a/packages/fleet-client/src/frontend.tsx b/packages/fleet-client/src/frontend.tsx index 5c1bf86..e6300e7 100644 --- a/packages/fleet-client/src/frontend.tsx +++ b/packages/fleet-client/src/frontend.tsx @@ -14,6 +14,5 @@ if (import.meta.hot) { const root = (import.meta.hot.data.root ??= createRoot(elem)); root.render(app); } else { - // The hot module reloading API is not available in production. createRoot(elem).render(app); } diff --git a/packages/fleet-client/src/index.ts b/packages/fleet-client/src/index.ts index b284922..821dd69 100644 --- a/packages/fleet-client/src/index.ts +++ b/packages/fleet-client/src/index.ts @@ -13,7 +13,6 @@ import { } from "webterm/protocol"; import index from "./index.html"; -/** Per-connection state for a proxied `/bridge/*` WebSocket. */ export interface BridgeWsData { upstream: WebSocket; /** Frames the browser sent before `upstream` reached OPEN (e.g. the terminal's first `init`). */ @@ -86,13 +85,8 @@ export function startClientServer( port?: number, deps?: { createWebSocket?: CreateWebSocket }, ) { - /** - * Real bridge origin the `/bridge/*` proxy forwards to. Configure with the - * `BRIDGE_URL` env var; defaults to a local bridge. - */ const bridgeWSUrl = bridgeUrl.replace(/^http/, "ws"); - /** Strip the `/bridge` prefix, preserving path + query (defaults to `/`). */ function bridgePath(url: URL): string { return (url.pathname.replace(/^\/bridge/, "") || "/") + url.search; diff --git a/packages/fleet-client/src/layouts/Shell.tsx b/packages/fleet-client/src/layouts/Shell.tsx index e76573a..0dffda3 100644 --- a/packages/fleet-client/src/layouts/Shell.tsx +++ b/packages/fleet-client/src/layouts/Shell.tsx @@ -7,10 +7,8 @@ import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; /** - * The persistent app frame: sidebar + top bar wrapping the routed page. The - * theme is applied here by toggling the `.dark` class that switches every - * Bridge design token (see styles/globals.css). On mobile the sidebar collapses - * into a slide-out drawer whose open state lives here. + * The theme is applied here by toggling the `.dark` class that switches every + * Bridge design token (see styles/globals.css). */ export function Shell({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) { const { error } = useFleet(); diff --git a/packages/fleet-client/src/layouts/Sidebar.tsx b/packages/fleet-client/src/layouts/Sidebar.tsx index da161fb..44919d1 100644 --- a/packages/fleet-client/src/layouts/Sidebar.tsx +++ b/packages/fleet-client/src/layouts/Sidebar.tsx @@ -2,7 +2,6 @@ import { NavLink } from "react-router-dom"; import { cn } from "@/lib/utils"; import { useFleet } from "@/data/FleetContext"; -/** Overlay that marks the current nav item: accent wash + accent left border. */ function ActiveFill() { return ( diff --git a/packages/fleet-client/src/layouts/TopBar.tsx b/packages/fleet-client/src/layouts/TopBar.tsx index 5554ef7..a13b7bc 100644 --- a/packages/fleet-client/src/layouts/TopBar.tsx +++ b/packages/fleet-client/src/layouts/TopBar.tsx @@ -2,7 +2,6 @@ import { useLocation } from "react-router-dom"; import { Menu } from "lucide-react"; import type { Theme } from "@/App"; -/** `bridge` / `bridge / armory` / `bridge / {repo}` / `bridge / {repo} / {name}` from the URL. */ function breadcrumb(pathname: string): string { const parts = pathname.split("/").filter(Boolean).map(decodeURIComponent); if (parts[0] === "armory") return "bridge / armory"; diff --git a/packages/fleet-client/src/lib/armory.ts b/packages/fleet-client/src/lib/armory.ts index b92660b..d5e026c 100644 --- a/packages/fleet-client/src/lib/armory.ts +++ b/packages/fleet-client/src/lib/armory.ts @@ -1,14 +1,6 @@ -/** - * lib/armory.ts — pure helpers behind the Armory page. - * - * They live outside the route so the derivations an operator actually reads — - * above all "is this ship in sync?" — can be unit-tested without rendering. - */ - import { ARMORY_SECTIONS } from "fleet-protocol"; import type { ArmorySection, ArmorySyncState } from "@/data/types"; -/** Group order for the file browser, straight from the protocol's own order. */ export const SECTION_ORDER: readonly ArmorySection[] = ARMORY_SECTIONS; /** @@ -36,7 +28,6 @@ export function abbreviateRevision(revision: string | null): string { return revision.slice(0, 12); } -/** The manifest path with its section prefix removed, since the section is the group heading. */ export function stripSection(path: string, section: string): string { return path.startsWith(`${section}/`) ? path.slice(section.length + 1) : path; } diff --git a/packages/fleet-client/src/lib/diff/diff-target.ts b/packages/fleet-client/src/lib/diff/diff-target.ts index b2ca203..c3e2f3c 100644 --- a/packages/fleet-client/src/lib/diff/diff-target.ts +++ b/packages/fleet-client/src/lib/diff/diff-target.ts @@ -1,12 +1,3 @@ -/** - * diff-target.ts — what the diff viewer is pointed at, and how that becomes a - * bridge `GET /workspaces/:repo/:name/diff` query. - * - * Three targets cover what a reviewer of an agent's workspace needs to see: - * the uncommitted work in the tree, everything the branch has done relative to - * another branch, and the tail end of the branch's own history. - */ - export type DiffTarget = | { kind: "working" } | { kind: "branch"; base: string; includeWorking: boolean } @@ -48,7 +39,6 @@ export function diffQuery(target: DiffTarget): DiffQuery { } } -/** Human-facing summary of a target, shown in the toolbar and the empty state. */ export function describeDiffTarget(target: DiffTarget): string { switch (target.kind) { case "working": diff --git a/packages/fleet-client/src/lib/diff/parse-diff.ts b/packages/fleet-client/src/lib/diff/parse-diff.ts index 2ec532a..1476f85 100644 --- a/packages/fleet-client/src/lib/diff/parse-diff.ts +++ b/packages/fleet-client/src/lib/diff/parse-diff.ts @@ -1,4 +1,3 @@ -// Parses a unified `git diff` into a structured list of files. // Ported from the pipediff reference tool — a dependency-free state-machine parser. export type LineKind = "context" | "add" | "del" | "meta"; @@ -19,7 +18,6 @@ export interface DiffHunk { export type FileStatus = "modified" | "added" | "deleted" | "renamed"; export interface DiffFile { - // Stable identifier used by the UI. id: string; oldPath: string; newPath: string; @@ -38,11 +36,6 @@ function stripPrefix(p: string): string { return p; } -/** - * Parse a full unified diff (as produced by `git diff`) into files. - * Tolerant of the common variations: added/deleted/renamed files, binary - * files, and multiple files in one stream. - */ export function parseDiff(raw: string): DiffFile[] { const files: DiffFile[] = []; const lines = raw.split("\n"); @@ -135,7 +128,6 @@ export function parseDiff(raw: string): DiffFile[] { continue; } - // Body lines of a hunk. if (currentHunk) { if (line.startsWith("\\")) { // "\ No newline at end of file" — attach as meta. diff --git a/packages/fleet-client/src/lib/webterm/glyphs.ts b/packages/fleet-client/src/lib/webterm/glyphs.ts index 33907db..91cf83a 100644 --- a/packages/fleet-client/src/lib/webterm/glyphs.ts +++ b/packages/fleet-client/src/lib/webterm/glyphs.ts @@ -1,14 +1,9 @@ /** - * Geometric rendering of Unicode Block Elements (U+2580–U+259F) and Box Drawing - * (U+2500–U+257F). - * * A terminal cell is `FONT_SIZE * LINE_HEIGHT` tall — taller than the font glyph - * that fills it — so painting these glyphs as *text* leaves a seam of empty - * pixels between rows: solid block art (the startup mascot, progress bars) grows - * horizontal gaps and vertical box lines break into dashes. Native terminals - * avoid this by drawing box/block glyphs from geometry keyed to the cell bounds - * instead of from the font; that is what this module does, so the art stays - * contiguous at any line height, font, or zoom level. + * that fills it — so painting block/box glyphs as *text* leaves a seam of empty + * pixels between rows: solid block art grows horizontal gaps and vertical box + * lines break into dashes. Drawing them from geometry keyed to the cell bounds + * instead keeps the art contiguous at any line height, font, or zoom level. * * Returns `true` when `code` was handled (caller skips text rendering), `false` * otherwise (caller falls back to `fillText`). diff --git a/packages/fleet-client/src/lib/webterm/keys.ts b/packages/fleet-client/src/lib/webterm/keys.ts index 9908be7..02820de 100644 --- a/packages/fleet-client/src/lib/webterm/keys.ts +++ b/packages/fleet-client/src/lib/webterm/keys.ts @@ -1,19 +1,14 @@ /** - * keys.ts — encode a browser keydown into the raw bytes a PTY expects, matching - * xterm's default (non-application) keymap. Returned bytes go out as a webterm - * `InputMsg`; `null` means "not ours — let the browser handle it". + * Raw bytes a PTY expects, matching xterm's default (non-application) keymap; + * `null` means "not ours — let the browser handle it". * - * Modifiers on navigation/editing keys use xterm's parameterized CSI form: - * `CSI 1 ; ` for arrows/Home/End and `CSI ; ~` for the - * tilde keys, where ` = 1 + Shift(1) + Alt(2) + Ctrl(4)`. Shift-Tab is the - * back-tab (CBT) sequence `CSI Z`, which apps like Claude Code read to cycle - * modes — without it, Shift-Tab would collapse to a plain Tab. + * Shift-Tab is the back-tab (CBT) sequence `CSI Z`, which apps like Claude Code + * read to cycle modes — without it, Shift-Tab would collapse to a plain Tab. * * Known gap: no IME/composition handling (`compositionstart`/`end`), so composed * CJK input won't work. Acceptable for an ASCII-dominated agent/ops console. */ -/** The subset of `KeyboardEvent` this encoder reads. */ export interface KeyEventLike { readonly key: string; readonly ctrlKey: boolean; diff --git a/packages/fleet-client/src/lib/webterm/palette.ts b/packages/fleet-client/src/lib/webterm/palette.ts index 73a5ce1..9cadf2c 100644 --- a/packages/fleet-client/src/lib/webterm/palette.ts +++ b/packages/fleet-client/src/lib/webterm/palette.ts @@ -1,7 +1,4 @@ /** - * palette.ts — resolve a `WireColor` (the wire form from `webterm/protocol`) to a - * CSS color string for the canvas renderer. - * * The 256-color table is the xterm standard: 16 ANSI colors, then a 6×6×6 color * cube (indices 16–231), then a 24-step grayscale ramp (232–255). The first 16 * are tuned to the app's terminal palette (see `--color-term-*` in globals.css) diff --git a/packages/fleet-client/src/routes/ArmoryRoute.tsx b/packages/fleet-client/src/routes/ArmoryRoute.tsx index c8c7088..19d63ef 100644 --- a/packages/fleet-client/src/routes/ArmoryRoute.tsx +++ b/packages/fleet-client/src/routes/ArmoryRoute.tsx @@ -1,13 +1,3 @@ -/** - * ArmoryRoute — a read-only view of the bridge's armory: the files it hands out, - * the map that says where dotfiles land, and how far each ship has got applying - * them. - * - * The armory is edited on the bridge host, not here, so this page has no - * mutations of any kind. It is also the only page that fetches its own data — - * the armory is deliberately absent from the boot snapshot, since most sessions - * never open it. - */ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; diff --git a/packages/fleet-client/src/routes/ReposRoute.tsx b/packages/fleet-client/src/routes/ReposRoute.tsx index f285175..a27525e 100644 --- a/packages/fleet-client/src/routes/ReposRoute.tsx +++ b/packages/fleet-client/src/routes/ReposRoute.tsx @@ -196,7 +196,6 @@ export function ConfirmDeleteModal({ ); } -/** Mobile-only inline field label for the stacked-card table rows (hidden at `md`). */ export function RowLabel({ children }: { children: ReactNode }) { return ( diff --git a/packages/fleet-client/tests/armory-data.test.ts b/packages/fleet-client/tests/armory-data.test.ts index f23abe3..f67f2e4 100644 --- a/packages/fleet-client/tests/armory-data.test.ts +++ b/packages/fleet-client/tests/armory-data.test.ts @@ -1,12 +1,3 @@ -/** - * armory-data.test.ts — the Armory page's data layer: the three Eden calls it - * makes, the mock fixture it is developed against, and the pure helpers that - * decide what an operator reads on the page. - * - * The Eden half runs against a recording `Bun.serve`, so route, method and query - * are asserted as they go over the wire rather than through the treaty's types. - */ - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { EdenFleetBridge } from "../src/data/eden"; import { makeBridgeClient } from "../src/data/client"; diff --git a/packages/fleet-protocol/index.ts b/packages/fleet-protocol/index.ts index ee71327..53dbb25 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -1,8 +1,3 @@ -/** - * fleet-protocol — the shared API + config contract between the Fleet Ship host - * and the Fleet CLI. Pure types plus a couple of constants; no runtime deps. - */ - export { FleetIdentifierSchema, parseFleetIdentifier, diff --git a/packages/fleet-protocol/src/armory.ts b/packages/fleet-protocol/src/armory.ts index 5f591e6..8390139 100644 --- a/packages/fleet-protocol/src/armory.ts +++ b/packages/fleet-protocol/src/armory.ts @@ -1,25 +1,9 @@ /** - * src/armory.ts — the Armory contract: a bridge-owned directory of files the - * fleet's ships install. - * - * A human hand-edits (or git-syncs) `/armory/`: - * - * armory/ - * skills//SKILL.md plus any extra files the skill needs - * plugins//... one arbitrary tree per agent provider - * dotfiles/... arbitrary files and directories - * dotfile-map.json `dotfiles/`-relative source → destination - * - * The bridge scans that tree into an `ArmoryManifest`, whose `revision` is a - * content address: it changes iff a file's contents, mode, or path changes, or - * the dotfile map changes. Ships compare revisions to decide whether to re-pull, - * so `revision` must be a pure function of armory content — never of scan time, - * host paths, or filesystem ordering. - * - * Paths inside the manifest are always POSIX-separated and relative to the - * armory root (`skills/my-skill/SKILL.md`). `isSafeArmoryPath` is the single - * shared validator for them; both the bridge (when serving) and the ship (when - * installing) apply it, because a manifest is untrusted input on the ship side. + * `ArmoryManifest.revision` must be a pure function of armory content — never of + * scan time, host paths, or filesystem ordering — because ships compare + * revisions to decide whether to re-pull. A manifest is untrusted input on the + * ship side, so both the bridge (serving) and the ship (installing) apply + * `isSafeArmoryPath` to every path in it. */ import { z } from "zod"; @@ -41,12 +25,9 @@ const utf8 = new TextEncoder(); /** * Whether `path` is safe to join onto an armory root on either side of the wire. - * - * Rejects: empty, absolute (`/`-leading or `C:`-style), any `\` (so a Windows - * separator can never smuggle a segment past the `/` split), `.`/`..`/empty - * segments, control characters (NUL included), and anything over 1 KiB. - * A single path segment is a valid input too — the scanner checks directory - * entry names with it. + * Any `\` is rejected so a Windows separator can never smuggle a segment past + * the `/` split. A single path segment is a valid input too — the scanner checks + * directory entry names with it. */ export function isSafeArmoryPath(path: string): boolean { if (path.length === 0) return false; @@ -58,7 +39,6 @@ export function isSafeArmoryPath(path: string): boolean { return path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); } -/** A destination is only meaningful if it is home-rooted or absolute. */ function isSafeDotfileDestination(destination: string): boolean { if (CONTROL_CHARACTERS.test(destination)) return false; return destination.startsWith("~/") || destination.startsWith("/"); @@ -69,7 +49,6 @@ const ArmoryFileFactsSchema = z.object({ path: z.string().min(1).refine(isSafeArmoryPath, "must be a safe armory-relative path"), section: z.enum(ARMORY_SECTIONS), size: z.number().int().nonnegative(), - /** Lowercase hex sha256 of the file's bytes. */ sha256: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), /** Normalized to `0o755` (executable) or `0o644`; host mode bits never leak. */ mode: z.number().int(), @@ -83,12 +62,10 @@ export type ArmoryEntry = z.infer; * Keys are `armory/dotfiles/`-relative sources; values are `~/`-rooted or absolute * destinations. * - * Every issue is reported against the offending key and names which side of the - * pair is broken, because this schema validates a hand-edited file and its errors - * are read by whoever has to go and fix it. That is also why the value type is - * checked *inside* the refinement over a permissive base rather than by a - * `z.string()` value schema: zod skips refinements once the base parse fails, so - * a single mistyped value would otherwise hide every other bad entry in the file. + * The value type is checked *inside* the refinement over a permissive base rather + * than by a `z.string()` value schema because zod skips refinements once the base + * parse fails: a single mistyped value would otherwise hide every other bad entry + * in this hand-edited file. */ export const DotfileMapSchema = z .record(z.string().min(1), z.unknown()) @@ -124,7 +101,7 @@ export const DotfileMapSchema = z export type DotfileMap = z.infer; export const ArmoryManifestSchema = z.object({ - /** Content address of the whole armory: lowercase hex sha256. */ + /** Content address of the whole armory. */ revision: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), /** Every scanned file, sorted by `path`. */ entries: ArmoryEntrySchema.array(), @@ -133,7 +110,6 @@ export const ArmoryManifestSchema = z.object({ export type ArmoryManifest = z.infer; -/** One file's contents, carrying the same facts the manifest reports for it. */ export const ArmoryFileSchema = ArmoryFileFactsSchema.extend({ /** `utf8` when the bytes decode as text; `base64` for anything binary. */ encoding: z.enum(["utf8", "base64"]), @@ -178,7 +154,6 @@ export const ArmoryInstallSummarySchema = z.object({ export type ArmoryInstallSummary = z.infer; -/** What a ship reports about its armory cache. */ export const ArmorySyncStateSchema = z.object({ /** The applied revision; `null` until the first successful sync. */ revision: z.string().nullable(), diff --git a/packages/fleet-protocol/src/config.ts b/packages/fleet-protocol/src/config.ts index d85f32b..46e56fe 100644 --- a/packages/fleet-protocol/src/config.ts +++ b/packages/fleet-protocol/src/config.ts @@ -1,21 +1,9 @@ -/** - * src/config.ts — the Fleet Ship configuration contract. - * - * A ship is configured from CLI flags (`fleet ship --port --name --fleet-directory - * --bridge-url`). - * The canonical shape is the zod schema below; the host assembles an object from the - * flags then validates it against `FleetShipConfigSchema`, and `FleetShipConfig` is - * inferred from it so the type and the runtime validator can never drift. - */ - import { z } from "zod"; import { FleetIdentifierSchema } from "./identifier"; -/** Runtime validator for the ship configuration. */ export const FleetShipConfigSchema = z.object({ /** Directory that holds all workspaces, laid out as `//`. */ fleetDirectory: z.string().min(1), - /** Port the ship's HTTP + WebSocket API listens on. */ port: z.number().int(), /** Human-facing name of this ship (surfaced as `ship` on active workspace status). */ name: FleetIdentifierSchema, @@ -28,7 +16,6 @@ export const FleetShipConfigSchema = z.object({ bridgeUrl: z.url().optional(), }); -/** The ship configuration, inferred from the schema. */ export type FleetShipConfig = z.infer; /** @@ -46,9 +33,7 @@ export const ATLAS_FILENAME = "atlas.json"; /** Contents of `atlas.json` — how a workspace-local agent reaches its ship. */ export const AtlasSchema = z.object({ - /** Local port the ship's HTTP + WebSocket API is listening on. */ port: z.number().int(), }); -/** The parsed `atlas.json`, inferred from the schema. */ export type Atlas = z.infer; diff --git a/packages/fleet-protocol/src/events.ts b/packages/fleet-protocol/src/events.ts index 8fbac10..cd714c8 100644 --- a/packages/fleet-protocol/src/events.ts +++ b/packages/fleet-protocol/src/events.ts @@ -1,20 +1,8 @@ -/** - * src/events.ts — the read-only event stream pushed over the ship's `/events` - * WebSocket. It is a zod discriminated union so any consumer can decode a raw - * message in one call via `decodeFleetEvent`. - * - * Every event carries the `ship` that emitted it (so an aggregator connecting to - * many ships can tell them apart) and an ISO 8601 `at` timestamp. On connect the - * ship sends a `sync` snapshot of the current workspaces, then streams a change - * event for each relevant workspace state change. - */ - import { z } from "zod"; import { FleetIdentifierSchema } from "./identifier"; import { WorkspaceSummarySchema } from "./workspace"; const EventBase = z.object({ - /** Name of the ship (from its config) that emitted the event. */ ship: FleetIdentifierSchema, /** ISO 8601 timestamp of when the event was emitted. */ at: z.string(), @@ -49,7 +37,6 @@ export const WorkspaceDeactivatedEventSchema = EventBase.extend({ workspace: WorkspaceSummarySchema, }); -/** The agent attached to a workspace initialized or changed its live status. */ export const WorkspaceAgentStatusChangedEventSchema = EventBase.extend({ type: z.literal("workspace.agent_status_changed"), workspace: WorkspaceSummarySchema, @@ -75,10 +62,7 @@ export const FleetEventSchema = z.discriminatedUnion("type", [ export type SyncEvent = z.infer; export type FleetEvent = z.infer; -/** - * Decode a raw `/events` message (a JSON string, or an already-parsed object) - * into a validated `FleetEvent`. Throws a `ZodError` if it doesn't match. - */ +/** Accepts a JSON string or an already-parsed object. Throws `ZodError` on mismatch. */ export function decodeFleetEvent(raw: string | unknown): FleetEvent { return FleetEventSchema.parse(typeof raw === "string" ? JSON.parse(raw) : raw); } diff --git a/packages/fleet-protocol/src/repo.ts b/packages/fleet-protocol/src/repo.ts index 07594ea..d52695d 100644 --- a/packages/fleet-protocol/src/repo.ts +++ b/packages/fleet-protocol/src/repo.ts @@ -1,19 +1,9 @@ -/** - * src/repo.ts — the repo record the bridge owns and serves from `GET /repos`. - * - * A repo is a bridge-registered git project with a unique `name` (which is also - * the directory a workspace clone lands under on the ship) and a clone `url`. - * The runtime schemas keep persisted and service-boundary data aligned with the - * exported types. - */ - import { z } from "zod"; import { FleetIdentifierSchema } from "./identifier"; export const RepoSchema = z.object({ /** Unique repo name; also the ship-side directory under `fleetDirectory`. */ name: FleetIdentifierSchema, - /** Git clone URL. */ url: z.string(), /** Where the repo is hosted (e.g. "github", "gitlab", or "custom"). */ provider: z.string(), diff --git a/packages/fleet-protocol/src/system.ts b/packages/fleet-protocol/src/system.ts index ef7fb3f..303905d 100644 --- a/packages/fleet-protocol/src/system.ts +++ b/packages/fleet-protocol/src/system.ts @@ -1,13 +1,7 @@ /** - * src/system.ts — the system-resources DTO reported by a ship's - * `GET /system-resources` route (and re-exposed/aggregated by the bridge). - * - * A plain interface (like `WorkspaceStatus`): it travels over the typed Eden - * HTTP surface, so — unlike the `/events` payloads — no third party decodes it - * from a raw string and it needs no zod schema. + * No zod schema: this travels over the typed Eden HTTP surface, so nothing + * decodes it from a raw string the way `/events` payloads are decoded. */ - -/** A point-in-time snapshot of a host's system resources. */ export interface SystemResources { /** System uptime in seconds (`os.uptime()`). */ readonly uptimeSeconds: number; diff --git a/packages/fleet-protocol/src/workspace.ts b/packages/fleet-protocol/src/workspace.ts index 172b8dd..8a235e5 100644 --- a/packages/fleet-protocol/src/workspace.ts +++ b/packages/fleet-protocol/src/workspace.ts @@ -1,16 +1,6 @@ -/** - * src/workspace.ts — the workspace DTOs shared between the ship (host) and the CLI. - * - * A workspace is a git clone of `` on ``, living at - * `//`. It is identified by the `(repoName, name)` - * pair — names are unique only within a repo — and is either `active` (a tmux - * session exists) or `inactive` (only the directory exists). - */ - import { z } from "zod"; import { FleetIdentifierSchema } from "./identifier"; -/** The lifecycle phases an agent reports as it works a task. */ export const AGENT_STATES = ["idle", "planning", "building", "verifying", "awaiting"] as const; export type AgentState = (typeof AGENT_STATES)[number]; @@ -23,20 +13,14 @@ export const AgentStatusSchema = z.object({ harness: z.string(), }); -/** Status of the coding agent attached to an active workspace's session. */ export type AgentStatus = z.infer; -/** - * Summary row returned by `GET /workspaces` (list view). It is also embedded in - * the `/events` stream, so it is a zod schema (with the type inferred from it) — - * consumers can validate it directly. - */ +/** Returned by `GET /workspaces` and embedded in the `/events` stream. */ export const WorkspaceSummarySchema = z.object({ /** Unique name of the repo the workspace belongs to (also its ship directory). */ repoName: FleetIdentifierSchema, /** Workspace name, unique within its repo. */ name: FleetIdentifierSchema, - /** Currently checked-out branch. */ branch: z.string(), /** Whether a tmux session is currently up for this workspace. */ active: z.boolean(), @@ -46,7 +30,6 @@ export const WorkspaceSummarySchema = z.object({ export type WorkspaceSummary = z.infer; -/** Git diff summary for an active workspace. */ export const WorkspaceDiffSchema = z.object({ /** Lines added across the working tree. */ added: z.number(), @@ -58,12 +41,7 @@ export const WorkspaceDiffSchema = z.object({ export type WorkspaceDiff = z.infer; -/** - * Refs a workspace can be diffed against, returned by - * `GET /workspaces/:repo/:name/refs`. Feeds the diff viewer's target picker: - * `branches` populates the "compare against" list and `commits` labels the - * "last N commits" choices. - */ +/** Refs a workspace can be diffed against, returned by `GET /workspaces/:repo/:name/refs`. */ export const WorkspaceRefsSchema = z.object({ /** Checked-out branch, or `""` when HEAD is detached. */ current: z.string(), @@ -119,7 +97,6 @@ export interface UpdateAgentStatusRequest { /** Body of `POST /workspaces` — create a workspace by cloning `url` into `repoName`. */ export const CreateWorkspaceRequestSchema = z.object({ - /** Git clone URL. */ url: z.string(), /** Unique repo name; the directory the clone lands under on the ship. */ repoName: FleetIdentifierSchema, diff --git a/packages/fleet-ship/src/api/armory.ts b/packages/fleet-ship/src/api/armory.ts index e18a7c4..44a3a7e 100644 --- a/packages/fleet-ship/src/api/armory.ts +++ b/packages/fleet-ship/src/api/armory.ts @@ -1,10 +1,3 @@ -/** - * api/armory.ts — the ship's armory routes: the bridge pushes `/armory/sync` to - * say "re-pull and re-install", and anyone can read back what this ship - * currently has cached and applied. - * One Elysia chain so route types stay inferable for Eden. - */ - import { Elysia, t } from "elysia"; import { ArmoryCache, ArmorySyncError } from "../armory/armory-cache"; import { syncAndInstall } from "../armory/armory-sync"; diff --git a/packages/fleet-ship/src/api/events.ts b/packages/fleet-ship/src/api/events.ts index f456f44..09a70e2 100644 --- a/packages/fleet-ship/src/api/events.ts +++ b/packages/fleet-ship/src/api/events.ts @@ -1,9 +1,3 @@ -/** - * api/events.ts — the ship's read-only `/events` WebSocket, which fans workspace - * state-change events out to every connected client. One Elysia chain so route - * types stay inferable for Eden. - */ - import { Elysia } from "elysia"; import { BUFFER_LIMIT_CLOSE_CODE, diff --git a/packages/fleet-ship/src/api/http.ts b/packages/fleet-ship/src/api/http.ts index 65d0def..ee6a742 100644 --- a/packages/fleet-ship/src/api/http.ts +++ b/packages/fleet-ship/src/api/http.ts @@ -1,9 +1,3 @@ -/** - * api/http.ts — shared HTTP error mapping for the ship's Elysia plugins. - * - * A `WorkspaceError` carries the status to surface; anything else is a 500. - */ - import { WorkspaceError } from "../workspace-manager"; export function mapError(err: unknown): { status: number; body: { error: string } } { diff --git a/packages/fleet-ship/src/api/index.ts b/packages/fleet-ship/src/api/index.ts index 1afa7cc..474f421 100644 --- a/packages/fleet-ship/src/api/index.ts +++ b/packages/fleet-ship/src/api/index.ts @@ -1,11 +1,3 @@ -/** - * api/index.ts — composes the ship's Elysia app from its route plugins. - * - * Each plugin is a single Elysia chain, so `.use()` merges its route types - * into the parent and `App = ReturnType` carries the full - * merged surface for the CLI's Eden `treaty` client. - */ - import { Elysia } from "elysia"; import type { WorkspaceManager } from "../workspace-manager"; import type { FleetShipConfig } from "fleet-protocol"; @@ -24,6 +16,9 @@ export function createApp( terminalInitTimeoutMs?: number, armory?: ArmoryCache, ) { + // Every plugin must stay a single Elysia chain: `.use()` merges its route + // types into `App`, which the CLI's Eden `treaty` client depends on. + // // Terminal frames are JSON whose keys and blank-cell `0`s repeat heavily, so // permessage-deflate is worth an order of magnitude on them. Bun's client // WebSocket offers the extension by default, so the bridge→ship hop starts diff --git a/packages/fleet-ship/src/api/system-resources.ts b/packages/fleet-ship/src/api/system-resources.ts index b781546..c978729 100644 --- a/packages/fleet-ship/src/api/system-resources.ts +++ b/packages/fleet-ship/src/api/system-resources.ts @@ -1,14 +1,7 @@ -/** - * api/system-resources.ts — the ship's `GET /system-resources` route, as its own - * Elysia plugin. Reports a point-in-time snapshot of the host (uptime, OS, CPU, - * memory) via `node:os`. One Elysia chain so route types stay inferable for Eden. - */ - import os from "node:os"; import { Elysia } from "elysia"; import type { SystemResources } from "fleet-protocol"; -/** Aggregate CPU idle/total tick counts across all logical cores. */ function cpuTicks(): { idle: number; total: number } { let idle = 0; let total = 0; @@ -31,7 +24,6 @@ async function sampleCpuUsage(sampleMs = 100): Promise { return Math.min(1, Math.max(0, 1 - idle / total)); } -/** Collect a full system-resources snapshot for this host. */ export async function collectSystemResources(): Promise { const cpus = os.cpus(); const [load1, load5, load15] = os.loadavg(); @@ -65,7 +57,6 @@ export async function collectSystemResources(): Promise { }; } -/** Elysia plugin exposing `GET /system-resources`. */ export function systemResourcesPlugin() { return new Elysia({ name: "ship-system-resources" }).get( "/system-resources", diff --git a/packages/fleet-ship/src/api/workspaces.ts b/packages/fleet-ship/src/api/workspaces.ts index 5114a31..509bf4e 100644 --- a/packages/fleet-ship/src/api/workspaces.ts +++ b/packages/fleet-ship/src/api/workspaces.ts @@ -1,8 +1,3 @@ -/** - * api/workspaces.ts — the ship's workspace routes plus the per-workspace - * terminal WebSocket. One Elysia chain so route types stay inferable for Eden. - */ - import { Elysia, t } from "elysia"; import { AGENT_STATES } from "fleet-protocol"; import { diff --git a/packages/fleet-ship/src/armory/armory-cache.ts b/packages/fleet-ship/src/armory/armory-cache.ts index e6ac397..464288e 100644 --- a/packages/fleet-ship/src/armory/armory-cache.ts +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -1,33 +1,3 @@ -/** - * armory/armory-cache.ts — the ship's local mirror of a bridge's armory. - * - * The bridge pushes `POST /armory/sync {bridgeUrl, revision}`; this class does - * the pulling. It caches files and nothing else: turning the cache into - * installed skills, plugins, and dotfiles is a separate concern that reads from - * here (armory-installer.ts, wired up by armory-sync.ts). The one thing it - * keeps on that installer's behalf is the summary it reports back, so a single - * `state.json` answers "what did this ship pull, and what came of it". - * - * /.config/autosmith/fleet-ship/armory/ - * files/ mirrors the bridge's armory tree - * state.json the applied revision and its entry list - * - * The location is deliberate. It is *not* under the ship's `fleetDirectory`, - * where `WorkspaceManager` enumerates every top-level directory as a candidate - * repo and would walk the cache as if it were one. It is under HOME because - * that is the root the shared managed-file machinery validates every path - * against, so the installers built on top of this cache need no new roots. - * - * A manifest is untrusted network input: every path is re-validated with - * `isSafeArmoryPath`, every destination is proved a strict descendant of - * `files/`, and every downloaded body is verified against the manifest's sha256 - * before it lands. A single bad file fails the whole sync — a half-applied - * armory must never be recorded under a revision that promises all of it. - * - * The push also chooses *which* bridge to pull from, so it is pinned: see - * `requirePinnedBridge` for what that does and does not protect against. - */ - import { chmod, lstat, mkdir, readdir, rename, rm, rmdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join, relative, resolve, sep } from "node:path"; @@ -47,10 +17,11 @@ import { type DotfileMap, } from "fleet-protocol"; -/** The cache root, relative to the home directory. */ +// Under HOME, not the ship's `fleetDirectory`: `WorkspaceManager` enumerates +// every top-level directory there as a candidate repo, and HOME is the root the +// shared managed-file machinery already validates every path against. const CACHE_RELATIVE_PATH = join(".config", "autosmith", "fleet-ship", "armory"); -/** Where `ArmoryCache` keeps its mirror, for the installer that reads it back. */ export function armoryCacheDirectory(homeDirectory: string): string { return join(resolve(homeDirectory), CACHE_RELATIVE_PATH); } @@ -65,7 +36,6 @@ export async function cachedDotfileMap(cacheDirectory: string): Promise = Promise.resolve(); @@ -288,7 +257,6 @@ export class ArmoryCache { return applied; } - /** Bring one entry's file on disk in line with the manifest. */ private async materialize(base: string, entry: ArmoryEntry): Promise { const target = resolve(this.filesRoot, entry.path); if (!isStrictDescendant(this.filesRoot, target)) { @@ -399,7 +367,6 @@ export class ArmoryCache { return bridgeUrl.replace(/\/+$/, ""); } - /** Delete every cached file the manifest no longer names, and any directory that leaves empty. */ private async prune(keep: Set): Promise { await pruneDirectory(this.filesRoot, "", keep); } diff --git a/packages/fleet-ship/src/armory/armory-installer.ts b/packages/fleet-ship/src/armory/armory-installer.ts index 6dfd82d..c5cb823 100644 --- a/packages/fleet-ship/src/armory/armory-installer.ts +++ b/packages/fleet-ship/src/armory/armory-installer.ts @@ -1,31 +1,3 @@ -/** - * armory/armory-installer.ts — turn the cached armory into installed files. - * - * `ArmoryCache` mirrors the bridge's armory under `/files/`; this module - * is the half that acts on it: - * - * files/skills//** → //** - * files/plugins/// - * files/dotfiles/ → symlinked wherever the dotfile map says - * - * Skills are modelled because every provider agrees on what one is: a directory - * discovered under a skills root. Plugins are not — each tool's plugin layout - * differs and changes — so the armory author, who can read their own tool's - * docs, chooses the path and the ship simply places the file inside that - * provider's config root. A `plugins/` that is not a known provider is - * skipped with a warning rather than guessed at. - * - * Everything is written through `managed-fs`, never directly: that is what - * gives adopt/conflict semantics, a crash-recoverable manifest, and — via - * `session.remove` — an uninstall that refuses to delete a file the user has - * since edited. `/installed.json` records what this installer wrote, so - * the next run can tell "removed from the armory" from "never installed". - * - * Dotfiles are the exception: they are symlinked, not copied, which managed-fs - * cannot express. `dotfile-linker.ts` owns that phase and its own ownership - * record; this module only sequences it and folds its report into this one. - */ - import { lstat, readdir, rename, rm, rmdir } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; diff --git a/packages/fleet-ship/src/armory/armory-sync.ts b/packages/fleet-ship/src/armory/armory-sync.ts index 4b866b2..a98c886 100644 --- a/packages/fleet-ship/src/armory/armory-sync.ts +++ b/packages/fleet-ship/src/armory/armory-sync.ts @@ -1,12 +1,3 @@ -/** - * armory/armory-sync.ts — pull the armory, then install it. - * - * `ArmoryCache` stays a pure puller and `installArmory` a pure installer; this - * is the only place that knows both. It exists so the ship's route can stay a - * handler: the ordering rule — a failed install is reported without discarding - * the successful pull that preceded it — belongs here, not in HTTP glue. - */ - import type { ArmoryInstallSummary, ArmorySyncRequest, ArmorySyncState } from "fleet-protocol"; import { ArmoryCache, ArmorySyncError } from "./armory-cache"; import { installArmory, type ArmoryInstallReport } from "./armory-installer"; diff --git a/packages/fleet-ship/src/armory/dotfile-linker.ts b/packages/fleet-ship/src/armory/dotfile-linker.ts index fe15aaa..4e40041 100644 --- a/packages/fleet-ship/src/armory/dotfile-linker.ts +++ b/packages/fleet-ship/src/armory/dotfile-linker.ts @@ -1,33 +1,9 @@ -/** - * armory/dotfile-linker.ts — put the armory's dotfiles in place, as symlinks. - * - * `dotfile-map.json` names `dotfiles/`-relative sources and where each belongs: - * - * ".tmux.conf": "~/.tmux.conf" → ~/.tmux.conf -> /files/dotfiles/.tmux.conf - * "nvim": "~/.config/nvim" → ~/.config/nvim -> /files/dotfiles/nvim - * - * Links, not copies, and deliberately so: the cache is already an exact mirror - * of the bridge's armory, so a content edit reaches the user through the link on - * the next pull with nothing to reinstall, and a directory source is one link - * rather than a copy per file. This is the one place Fleet's blanket refusal to - * touch symlinks (see managed-fs.ts, which refuses them on every path it - * touches) is relaxed — and only for links this module can prove it created: - * every decision is made on `lstat`/`readlink` of the target itself, and a link - * is only ever replaced or removed while it still points inside this cache's - * `files/dotfiles/`. Anything else at a target belongs to the user or to their - * own dotfile manager and is left exactly as found. managed-fs cannot serve - * this: it manages regular files by content hash. Do not route dotfiles through - * it, and do not weaken its checks to make that possible. - * - * `/dotfiles.json` records the links this module put in place, so a - * mapping that later leaves the map can be undone without guessing. - * - * The map is untrusted network input. A destination is resolved against *this - * ship's* home directory and must land strictly inside it, so no bridge can - * make a fleet symlink into `/etc` or `/usr`. That confinement is lexical: a - * user who has symlinked a directory of their own home elsewhere is taken at - * their word, the same way the rest of their dotfile setup takes them. - */ +// Fleet refuses to touch symlinks everywhere else (see managed-fs.ts, which +// manages regular files by content hash); this module is the sole exception, +// and only for links it can prove point inside the cache's `files/dotfiles/`. +// Do not route dotfiles through managed-fs, and do not weaken its checks to +// make that possible. The map is untrusted network input, so a destination must +// resolve strictly inside this ship's home directory. import { lstat, mkdir, readlink, rename, rm, symlink, unlink } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; diff --git a/packages/fleet-ship/src/atlas.ts b/packages/fleet-ship/src/atlas.ts index ab5375e..e7a9965 100644 --- a/packages/fleet-ship/src/atlas.ts +++ b/packages/fleet-ship/src/atlas.ts @@ -1,11 +1,3 @@ -/** - * atlas.ts — writes the ship's `atlas.json` discovery file. - * - * The file lives at the root of the ship's data directory (`fleetDirectory`). - * Since workspaces live at `//`, an agent inside a - * workspace can walk up to find it and learn the local port to reach the ship. - */ - import { lstat, open, rename, unlink } from "node:fs/promises"; import { basename, join } from "node:path"; import { ATLAS_FILENAME, type Atlas } from "fleet-protocol"; diff --git a/packages/fleet-ship/src/config.ts b/packages/fleet-ship/src/config.ts index 0e37d54..331fd9e 100644 --- a/packages/fleet-ship/src/config.ts +++ b/packages/fleet-ship/src/config.ts @@ -1,16 +1,7 @@ -/** - * config.ts — resolves and canonicalizes the Fleet Ship configuration. - * - * A ship is configured from CLI flags (see `index.ts`); this file validates a - * flag-assembled object against the shared `FleetShipConfigSchema` (owned by - * `fleet-protocol`) and handles resolving/canonicalizing `fleetDirectory`. - */ - import { mkdir, realpath } from "node:fs/promises"; import { resolve } from "node:path"; import { FleetShipConfigSchema, type FleetShipConfig } from "fleet-protocol"; -/** Validate a raw (flag-assembled) config, resolving `fleetDirectory` to an absolute path. */ export function resolveFleetShipConfig(raw: unknown): FleetShipConfig { const config = FleetShipConfigSchema.parse(raw); return { ...config, fleetDirectory: resolve(config.fleetDirectory) }; diff --git a/packages/fleet-ship/src/index.ts b/packages/fleet-ship/src/index.ts index c64054d..40e39f0 100755 --- a/packages/fleet-ship/src/index.ts +++ b/packages/fleet-ship/src/index.ts @@ -116,10 +116,6 @@ function parsePort(value: string): number { return port; } -/** - * Bring up a ship: canonicalize its fleet directory, install agent integrations, - * serve the API, and publish the `atlas.json` discovery file. Throws on failure. - */ export async function startShip(config: FleetShipConfig): Promise { // Deferred so merely mounting this subcommand in the unified CLI doesn't // eagerly pull in tmux-bun/webterm. They're only needed when the ship diff --git a/packages/fleet-ship/src/managed-fs.ts b/packages/fleet-ship/src/managed-fs.ts index 9741506..7cb525d 100644 --- a/packages/fleet-ship/src/managed-fs.ts +++ b/packages/fleet-ship/src/managed-fs.ts @@ -1,5 +1,3 @@ -/** Filesystem ownership and atomic writes shared by Fleet's integration installers. */ - import { lstat, mkdir, diff --git a/packages/fleet-ship/src/plugin-command.ts b/packages/fleet-ship/src/plugin-command.ts index 9445428..9b133c4 100644 --- a/packages/fleet-ship/src/plugin-command.ts +++ b/packages/fleet-ship/src/plugin-command.ts @@ -1,12 +1,3 @@ -/** - * plugin-command.ts — the `ship plugin` command group. - * - * `doctor` reports, read-only, the install state of the fleet-agent skill and - * the startup plugin for every provider. `install ` (re)installs - * both the skill and the plugin for one provider or all of them, reusing the - * same installers the ship runs on boot. - */ - import { homedir } from "node:os"; import { Command } from "commander"; import { @@ -26,7 +17,6 @@ import type { PresenceState } from "./managed-fs"; /** Providers a user may pass to `install`; codex has a skill but no plugin. */ const PROVIDERS = ["claude-code", "opencode", "copilot", "codex"] as const; -/** Display order for `doctor`, matching PROVIDERS. */ const DISPLAY_ORDER = PROVIDERS; /** The command each provider's harness is invoked as — what a skill/plugin is useless without. */ diff --git a/packages/fleet-ship/src/plugin-installer.ts b/packages/fleet-ship/src/plugin-installer.ts index b835bf5..f636e3f 100644 --- a/packages/fleet-ship/src/plugin-installer.ts +++ b/packages/fleet-ship/src/plugin-installer.ts @@ -1,27 +1,3 @@ -/** - * plugin-installer.ts — install the startup plugin/hook that tells an agent to - * activate the `fleet-agent` skill when it boots inside a fleet workspace. - * - * Each supported provider's plugin runs the same logic — `fagent agent - * in-workspace`, and on success inject an "activate the fleet-agent skill" - * instruction — but the packaging differs per provider: - * - * - claude-code: a plugin directory tree auto-loaded from `~/.claude/skills/` - * (`.claude-plugin/plugin.json` + a SessionStart command hook). - * - opencode: a single `session.start` plugin module auto-loaded from - * `~/.config/opencode/plugins/`. - * - copilot: a single `sessionStart` hook JSON auto-loaded from - * `~/.copilot/hooks/`. - * - * All three install by mirroring source files into an auto-discovered location, - * so they share one symlink-safe copy routine (see managed-fs.ts). Codex is not - * handled here: it has no drop-in directory and requires the `codex plugin` CLI - * plus a manual hook-trust step, so it can't be installed unattended — see - * docs/codex.md. - * - * Source plugins live under `packages/fleet-ship/plugins/`. - */ - import { homedir } from "node:os"; import { dirname, join } from "node:path"; import claudeManifest from "../plugins/claude-code/.claude-plugin/plugin.json" with { type: "text" }; @@ -86,7 +62,6 @@ type PluginSpec = { files: () => Promise; }; -/** Map every file in a source directory tree onto the destination directory. */ async function treeFiles(sourceDir: string, destinationDir: string): Promise { const mappings: FileMapping[] = []; for await (const relative of new Bun.Glob("**/*").scan({ cwd: sourceDir, dot: true })) { @@ -162,7 +137,6 @@ function pluginSpecs(homeDirectory: string, pluginsDir?: string): PluginSpec[] { ]; } -/** The plugin specs to act on, optionally narrowed to `providers`. */ function selectedSpecs( homeDirectory: string, pluginsDir?: string, @@ -284,9 +258,8 @@ export async function installFleetPlugin( } /** - * Report the install state of the plugin for each provider, without writing. - * A provider's files are aggregated into a single state. Codex isn't included — - * it has no drop-in plugin (see the module header). + * Codex is not included: it has no drop-in plugin directory and needs a manual + * hook-trust step, so it cannot be installed unattended — see docs/codex.md. */ export async function inspectFleetPlugin( options: InspectFleetPluginOptions = {}, diff --git a/packages/fleet-ship/src/providers.ts b/packages/fleet-ship/src/providers.ts index 7319746..cf6223d 100644 --- a/packages/fleet-ship/src/providers.ts +++ b/packages/fleet-ship/src/providers.ts @@ -1,15 +1,3 @@ -/** - * providers.ts — the agent providers Fleet installs into, and where each of - * them keeps its configuration. - * - * One source of truth for the embedded `fleet-agent` installers and for the - * armory installer, which fan out over the same rows. Two rules hold - * everywhere: a provider counts as present on this host iff its `configRoot` - * exists (Fleet never creates it — that would fake an install of a tool the - * user does not have), and a skill is written to *every* root - * `skillRootsFor` reports. - */ - import { join } from "node:path"; export type Provider = "claude-code" | "opencode" | "copilot" | "codex"; diff --git a/packages/fleet-ship/src/skill-installer.ts b/packages/fleet-ship/src/skill-installer.ts index cad372f..27d65c2 100644 --- a/packages/fleet-ship/src/skill-installer.ts +++ b/packages/fleet-ship/src/skill-installer.ts @@ -1,11 +1,3 @@ -/** - * skill-installer.ts — install the `fleet-agent` SKILL.md into each agent - * provider's skills directory. - * - * This module owns *skill* installation only. The startup plugins/hooks that - * tell an agent to activate the skill live in plugin-installer.ts. - */ - import { homedir } from "node:os"; import { dirname, join } from "node:path"; // TypeScript resolves the source extension before Bun's text-loader override. @@ -70,7 +62,6 @@ function providerPaths(homeDirectory: string): ProviderPaths[] { ); } -/** The provider spec rows to act on, optionally narrowed to `providers`. */ function selectedPaths(homeDirectory: string, providers?: readonly string[]): ProviderPaths[] { const all = providerPaths(homeDirectory); return providers ? all.filter((paths) => providers.includes(paths.provider)) : all; @@ -140,10 +131,7 @@ export async function installFleetSkill( return installations; } -/** - * Report the install state of the skill for each provider spec row, without - * writing anything. Codex contributes two rows (native + shared `~/.agents`). - */ +/** Codex contributes two rows (native + shared `~/.agents`). */ export async function inspectFleetSkill( options: InspectFleetSkillOptions = {}, ): Promise { diff --git a/packages/fleet-ship/src/workspace-manager.ts b/packages/fleet-ship/src/workspace-manager.ts index b857057..5897f21 100644 --- a/packages/fleet-ship/src/workspace-manager.ts +++ b/packages/fleet-ship/src/workspace-manager.ts @@ -1,12 +1,3 @@ -/** - * workspace-manager.ts — owns the on-disk workspace layout, the tmux namespace - * that tracks active/inactive state, and the git operations on each workspace. - * - * A workspace is a git clone of a repo on a branch, living at - * `//`, where `` is the bridge-assigned - * repo name. It is identified by the `(repoName, name)` pair. - */ - import { lstat, readdir, rm } from "node:fs/promises"; import { dirname } from "node:path"; import { Git, GitError, type DiffOptions } from "git-bun"; @@ -185,18 +176,15 @@ export class WorkspaceManager { for (const listener of this.listeners) listener(event); } - /** Common event fields: the emitting ship's name and an ISO timestamp. */ private stamp(): { ship: string; at: string } { return { ship: this.config.name, at: new Date().toISOString() }; } - /** Map key for the `(repoName, name)` pair that identifies a workspace. */ private key(repoName: string, name: string): string { this.validateIdentifiers(repoName, name); return `${repoName}/${name}`; } - /** Deterministic tmux session name for a `(repoName, name)` pair. */ sessionName(repoName: string, name: string): string { this.validateIdentifiers(repoName, name); return workspaceSessionName(repoName, name); @@ -394,7 +382,6 @@ export class WorkspaceManager { return status; } - /** Current agent status for a workspace, or `null` if none is attached. */ agentStatus(repoName: string, name: string): AgentStatus | null { this.validateIdentifiers(repoName, name); return this.agentStatuses.get(this.key(repoName, name)) ?? null; diff --git a/packages/fleet-ship/tests/api.test.ts b/packages/fleet-ship/tests/api.test.ts index b6b6bbd..33c0d95 100644 --- a/packages/fleet-ship/tests/api.test.ts +++ b/packages/fleet-ship/tests/api.test.ts @@ -1,10 +1,3 @@ -/** - * api.test.ts — drives the ship's composed Elysia app in-process via - * `app.handle(Request)` over a stub WorkspaceManager (no tmux/git). Asserts route - * wiring, the `active` query parsing, status codes, and the `WorkspaceError → status` - * mapping from `api/http.ts`. - */ - import { describe, expect, test } from "bun:test"; import { createApp } from "../src/api"; import { WorkspaceError } from "../src/workspace-manager"; diff --git a/packages/fleet-ship/tests/armory-cache.test.ts b/packages/fleet-ship/tests/armory-cache.test.ts index cdaae13..b20ca52 100644 --- a/packages/fleet-ship/tests/armory-cache.test.ts +++ b/packages/fleet-ship/tests/armory-cache.test.ts @@ -1,10 +1,6 @@ -/** - * armory-cache.test.ts — drives `ArmoryCache` against a real HTTP bridge - * (`Bun.serve`) and a temp home. The fake bridge is real rather than a stubbed - * `fetch` because the whole point of the cache is what it does with bytes off - * the wire; it also counts requests, which is how "downloads nothing" is - * asserted. - */ +// The fake bridge is a real `Bun.serve` rather than a stubbed `fetch` because +// the point of the cache is what it does with bytes off the wire; it also counts +// requests, which is how "downloads nothing" is asserted. import { afterEach, describe, expect, test } from "bun:test"; import { lstat, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; diff --git a/packages/fleet-ship/tests/armory-installer.test.ts b/packages/fleet-ship/tests/armory-installer.test.ts index e5fb742..81c3592 100644 --- a/packages/fleet-ship/tests/armory-installer.test.ts +++ b/packages/fleet-ship/tests/armory-installer.test.ts @@ -1,9 +1,5 @@ -/** - * armory-installer.test.ts — drives `installArmory` against a temp home holding - * both a fabricated armory cache and fabricated provider config roots. The - * cache is written as real files rather than mocked because what the installer - * has to get right is bytes and modes reaching provider directories. - */ +// The cache is written as real files rather than mocked because what the +// installer has to get right is bytes and modes reaching provider directories. import { afterEach, describe, expect, test } from "bun:test"; import { chmod, lstat, mkdir, mkdtemp, readlink, rm, stat } from "node:fs/promises"; diff --git a/packages/fleet-ship/tests/armory-sync.test.ts b/packages/fleet-ship/tests/armory-sync.test.ts index 327db1d..cd6452a 100644 --- a/packages/fleet-ship/tests/armory-sync.test.ts +++ b/packages/fleet-ship/tests/armory-sync.test.ts @@ -1,8 +1,5 @@ -/** - * armory-sync.test.ts — the pull-then-install orchestration. The bridge is a - * stub `fetch` serving an empty manifest because what is under test is the - * ordering of the two halves, not the pull itself (see armory-cache.test.ts). - */ +// The bridge is a stub `fetch` serving an empty manifest: what is under test is +// the ordering of pull-then-install, not the pull (see armory-cache.test.ts). import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; diff --git a/packages/fleet-ship/tests/atlas.test.ts b/packages/fleet-ship/tests/atlas.test.ts index 4afb1a8..6817b15 100644 --- a/packages/fleet-ship/tests/atlas.test.ts +++ b/packages/fleet-ship/tests/atlas.test.ts @@ -1,8 +1,3 @@ -/** - * atlas.test.ts — verifies the ship's `atlas.json` discovery file is written - * with the reachable port and validates against the shared `AtlasSchema`. - */ - import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, readdir, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/fleet-ship/tests/dotfile-linker.test.ts b/packages/fleet-ship/tests/dotfile-linker.test.ts index 5525100..283e00a 100644 --- a/packages/fleet-ship/tests/dotfile-linker.test.ts +++ b/packages/fleet-ship/tests/dotfile-linker.test.ts @@ -1,10 +1,5 @@ -/** - * dotfile-linker.test.ts — drives `linkDotfiles` against a temp home holding a - * fabricated armory cache. Links are checked with `lstat`/`readlink` as well as - * by reading through them: "the content is right" and "it is a symlink into the - * cache" are separate claims, and only the second one distinguishes this - * installer from a copy. - */ +// Links are checked with `lstat`/`readlink` as well as by reading through them: +// only "it is a symlink into the cache" distinguishes this installer from a copy. import { afterEach, describe, expect, test } from "bun:test"; import { lstat, mkdir, mkdtemp, readlink, rm, symlink, writeFile } from "node:fs/promises"; diff --git a/packages/fleet-ship/tests/events-ws.test.ts b/packages/fleet-ship/tests/events-ws.test.ts index ded6b32..19809e6 100644 --- a/packages/fleet-ship/tests/events-ws.test.ts +++ b/packages/fleet-ship/tests/events-ws.test.ts @@ -1,9 +1,3 @@ -/** - * events-ws.test.ts — exercises the ship's read-only `/events` WebSocket over a - * real ephemeral-port server: snapshot-on-connect, fan-out broadcast to every - * client, and continued delivery after one client disconnects. - */ - import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { FleetEvent } from "fleet-protocol"; import { createApp } from "../src/api"; @@ -80,14 +74,12 @@ describe("ship /events WebSocket", () => { await Promise.all([opened(a), opened(b)]); await Promise.all([nextMessage(a), nextMessage(b)]); // drain both snapshots - // Both receive the first broadcast. const aFirst = nextMessage(a); const bFirst = nextMessage(b); emit(created("one")); expect((await aFirst).type).toBe("workspace.created"); expect((await bFirst).type).toBe("workspace.created"); - // Close a; b keeps receiving. a.close(); await new Promise((r) => setTimeout(r, 20)); // let the server observe the close const bSecond = nextMessage(b); diff --git a/packages/fleet-ship/tests/helpers.ts b/packages/fleet-ship/tests/helpers.ts index 5466c63..708aa9a 100644 --- a/packages/fleet-ship/tests/helpers.ts +++ b/packages/fleet-ship/tests/helpers.ts @@ -1,8 +1,4 @@ -/** - * helpers.ts — a stub `WorkspaceManager` for exercising the ship's API layer in - * isolation (no tmux/git). Only the methods the routes call are implemented; - * override any of them per test to assert error mapping and status codes. - */ +// Only the methods the routes call are implemented; override any of them per test. import type { WorkspaceManager } from "../src/workspace-manager"; import { workspaceSessionName } from "../src/workspace-session"; diff --git a/packages/fleet-ship/tests/managed-fs-remove.test.ts b/packages/fleet-ship/tests/managed-fs-remove.test.ts index 56d3e19..64e686b 100644 --- a/packages/fleet-ship/tests/managed-fs-remove.test.ts +++ b/packages/fleet-ship/tests/managed-fs-remove.test.ts @@ -1,8 +1,3 @@ -/** - * managed-fs-remove.test.ts — `ManagedFileSession.remove`, the uninstall half of - * the managed-file contract, asserted directly rather than through an installer. - */ - import { afterEach, describe, expect, test } from "bun:test"; import { lstat, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/packages/fleet-ship/tests/skill-installer.test.ts b/packages/fleet-ship/tests/skill-installer.test.ts index 1cc6f51..1994fc6 100644 --- a/packages/fleet-ship/tests/skill-installer.test.ts +++ b/packages/fleet-ship/tests/skill-installer.test.ts @@ -200,16 +200,13 @@ describe("installFleetSkill", () => { const { homeDirectory, sourcePath } = fixtureOptions; const source = await Bun.file(sourcePath).text(); - // absent: no config root at all yet. let statuses = await inspectFleetSkill(fixtureOptions); expect(statuses.every((status) => status.state === "absent")).toBe(true); - // claude-code present but not installed → missing. await mkdir(join(homeDirectory, ".claude")); statuses = await inspectFleetSkill({ ...fixtureOptions, providers: ["claude-code"] }); expect(statuses[0]?.state).toBe("missing"); - // install → current. await installFleetSkill({ ...fixtureOptions, providers: ["claude-code"] }); statuses = await inspectFleetSkill({ ...fixtureOptions, providers: ["claude-code"] }); expect(statuses[0]?.state).toBe("current"); diff --git a/packages/fleet-ship/tests/workspace-manager.test.ts b/packages/fleet-ship/tests/workspace-manager.test.ts index 2239a70..cc730dc 100644 --- a/packages/fleet-ship/tests/workspace-manager.test.ts +++ b/packages/fleet-ship/tests/workspace-manager.test.ts @@ -191,7 +191,6 @@ suite("WorkspaceManager end-to-end", () => { }); afterAll(async () => { - // Clean up any tmux sessions this suite may have started. const active = await manager.list("active"); for (const w of active) { await manager.deactivate(w.repoName, w.name).catch(() => {}); @@ -355,7 +354,6 @@ suite("WorkspaceManager end-to-end", () => { ]); for (const event of events) expect(event.ship).toBe("test-ship"); - // Spot-check embedded summaries reflect the resulting state. const activated = events.find((e) => e.type === "workspace.activated"); if (activated && activated.type === "workspace.activated") { expect(activated.workspace.active).toBe(true); diff --git a/packages/git-bun/index.test.ts b/packages/git-bun/index.test.ts index e477da1..b992cd3 100644 --- a/packages/git-bun/index.test.ts +++ b/packages/git-bun/index.test.ts @@ -13,8 +13,7 @@ import { } from "./index"; // Deterministic identity so commits never fail on missing user.name/user.email, -// regardless of the machine's global git config (the analog of tmux-bun's -// `configFile: "/dev/null"` determinism trick). +// regardless of the machine's global git config. const IDENTITY: Record = { GIT_AUTHOR_NAME: "git-bun test", GIT_AUTHOR_EMAIL: "test@example.com", @@ -22,8 +21,7 @@ const IDENTITY: Record = { GIT_COMMITTER_EMAIL: "test@example.com", }; -// --- pure parsers — no git required, so these always run -------------------- - +// Pure parsers — no git required, so these always run. describe("parseLog", () => { test("splits records by line and fields by the unit separator", () => { const S = String.fromCharCode(0x1f); @@ -240,8 +238,6 @@ describe("parseLsRemote", () => { }); }); -// --- end-to-end against a real git binary ----------------------------------- - const gitAvailable = await (async () => { try { return (await Bun.$`git --version`.quiet().nothrow()).exitCode === 0; @@ -256,7 +252,7 @@ if (!gitAvailable) { } suite("git-bun end-to-end", () => { - let root: string; // throwaway parent dir holding all repos for the suite + let root: string; beforeAll(async () => { root = await mkdtemp(join(tmpdir(), "git-bun-test-")); @@ -270,7 +266,6 @@ suite("git-bun end-to-end", () => { const repo = await Git.init(dir, { initialBranch: "main", env: IDENTITY }); expect(repo.cwd).toBe(dir); expect(await repo.isRepo()).toBe(true); - // A fresh dir that was never init'd is not a repo. const bare = new Git({ cwd: root }); expect(await bare.isRepo()).toBe(false); }); @@ -306,7 +301,6 @@ suite("git-bun end-to-end", () => { await repo.add("."); await repo.commit("initial commit"); - // Modify the tracked file (unstaged) and create a brand-new untracked file. await Bun.write(join(dir, "tracked.txt"), "one\ntwo changed\n"); await Bun.write(join(dir, "brand-new.txt"), "fresh line\n"); @@ -315,7 +309,6 @@ suite("git-bun end-to-end", () => { expect(plain).toContain("tracked.txt"); expect(plain).not.toContain("brand-new.txt"); - // With includeUntracked it is appended as a `new file` add-diff. const full = await repo.diff({ range: "HEAD", includeUntracked: true }); expect(full).toContain("tracked.txt"); expect(full).toContain("+two changed"); @@ -411,7 +404,6 @@ suite("git-bun end-to-end", () => { expect(await repo.currentBranch()).toBe("feature"); expect((await repo.branches()).find((b) => b.name === "feature")?.current).toBe(true); - // checkout with create is the other entry point. await repo.checkout("second", { create: true }); expect(await repo.currentBranch()).toBe("second"); diff --git a/packages/git-bun/index.ts b/packages/git-bun/index.ts index 147d77d..45cc521 100644 --- a/packages/git-bun/index.ts +++ b/packages/git-bun/index.ts @@ -1,12 +1,3 @@ -// git-bun: a typed, headless API over the git CLI for Bun. -// -// The Git class is the primary way to drive git. Every instance is bound to a -// working directory at construction and confined to it — creating a worktree, -// cloning, or initializing returns a new Git handle bound to the resulting -// directory. The low-level GitCommand helper is exported as an escape hatch for -// subcommands this library does not wrap, and GitBackend is the seam for -// alternative transports. - export { Git, type GitOptions } from "./src/git"; export { diff --git a/packages/git-bun/src/backend.ts b/packages/git-bun/src/backend.ts index 62df9a2..f5522a3 100644 --- a/packages/git-bun/src/backend.ts +++ b/packages/git-bun/src/backend.ts @@ -1,9 +1,3 @@ -// The transport seam. Every git invocation in this library flows through a -// GitBackend, so an alternative backend (e.g. a long-lived `git cat-file --batch` -// process, or a libgit2 binding) could be dropped in without touching any call -// site — only this interface must be satisfied. - -/** Raw result of a single `git` invocation. */ export interface GitRunResult { readonly stdout: string; readonly stderr: string; @@ -11,9 +5,8 @@ export interface GitRunResult { } /** - * A transport that runs one git command and reports its raw result. The `args` - * it receives already include the `-C ` working-directory flags, so a - * backend must never inject its own — it just executes what it is given. + * The `args` a backend receives already include the `-C ` working-directory + * flags, so a backend must never inject its own. */ export interface GitBackend { run(args: readonly string[]): Promise; diff --git a/packages/git-bun/src/command.ts b/packages/git-bun/src/command.ts index ad8d428..b98ed8a 100644 --- a/packages/git-bun/src/command.ts +++ b/packages/git-bun/src/command.ts @@ -1,7 +1,6 @@ import { ShellBackend, type GitBackend, type GitRunResult } from "./backend"; import { GitError } from "./errors"; -/** Construction options shared by {@link GitCommand} and the root {@link Git}. */ export interface GitCommandOptions { /** * Working directory, injected as `-C ` on every invocation. Git resolves @@ -23,8 +22,7 @@ export interface GitCommandOptions { /** * The single choke point through which every git command passes. It prepends * `-C ` to every invocation, which is what makes the working directory a - * hard guarantee: no higher-level method can construct a call that runs against - * a different directory, because none of them touch the `-C` flag at all. + * hard guarantee: no higher-level method touches the `-C` flag at all. */ export class GitCommand { readonly cwd: string; @@ -36,9 +34,8 @@ export class GitCommand { } /** - * The working-directory flags prepended to every command. `-C ` runs git - * as if it had been started in `cwd`, without changing the parent process's - * own working directory. + * `-C ` runs git as if it had been started in `cwd`, without changing the + * parent process's own working directory. */ private globalArgs(): readonly string[] { return ["-C", this.cwd]; diff --git a/packages/git-bun/src/format.ts b/packages/git-bun/src/format.ts index 0221d64..cfe4fb0 100644 --- a/packages/git-bun/src/format.ts +++ b/packages/git-bun/src/format.ts @@ -11,8 +11,6 @@ function toInt(value: string | undefined): number { return Number.isNaN(n) ? 0 : n; } -// --- log ------------------------------------------------------------------- - // %H sha, %h short sha, %an author name, %ae author email, %at author date // (unix seconds), %s subject. Every field is single-line, so one commit is one // output line and records split cleanly on "\n". @@ -38,8 +36,6 @@ export function parseLog(stdout: string): CommitInfo[] { }); } -// --- status (porcelain v2) ------------------------------------------------- - /** * Parse `git status --porcelain=v2 -z --branch` output. NUL termination keeps * paths verbatim instead of applying Git's configurable C-style quoting. @@ -147,8 +143,6 @@ function malformedStatusRecord(kind: string, reason: string): Error { return new Error(`Malformed git status porcelain v2 ${JSON.stringify(kind)} record: ${reason}`); } -// --- worktree list (porcelain) --------------------------------------------- - export function parseWorktrees(stdout: string): WorktreeInfo[] { return stdout .split("\n\n") @@ -174,8 +168,6 @@ export function parseWorktrees(stdout: string): WorktreeInfo[] { }); } -// --- branch (--format) ----------------------------------------------------- - // %(refname:short) name, %(objectname) sha, %(HEAD) "*" for the current branch, // %(upstream:short) tracking branch (empty when unset). const BRANCH_FIELDS = ["%(refname:short)", "%(objectname)", "%(HEAD)", "%(upstream:short)"] as const; @@ -198,8 +190,6 @@ export function parseBranches(stdout: string): BranchInfo[] { }); } -// --- ls-remote ------------------------------------------------------------- - // `\t` per line. The sha is the remote's full hash — 40 hex chars for // SHA-1, 64 for SHA-256 — and is never abbreviated, so anything else on the left // is not a ref record: `--symref` prefixes the listing with `ref: \tHEAD`. diff --git a/packages/git-bun/src/git.ts b/packages/git-bun/src/git.ts index 2ed3f94..73bb954 100644 --- a/packages/git-bun/src/git.ts +++ b/packages/git-bun/src/git.ts @@ -40,24 +40,17 @@ import type { WorktreeRemoveOptions, } from "./types"; -/** Construction options for {@link Git}. */ export type GitOptions = GitCommandOptions; /** - * Root handle for a single git working directory. Every operation reachable - * from a `Git` instance runs against its `cwd`: the underlying - * {@link GitCommand} injects `-C ` into every invocation, so no method can - * touch a repository outside the directory this handle is bound to. - * - * Operations that produce a new working directory — {@link init}, {@link clone}, - * {@link worktreeAdd} — return a fresh `Git` bound to that directory, so an - * orchestrator can hand the returned handle straight to whatever will work in it. + * Every operation reachable from a `Git` instance runs against its `cwd`: the + * underlying {@link GitCommand} injects `-C ` into every invocation, so no + * method can touch a repository outside the directory this handle is bound to. */ export class Git { /** - * The low-level command helper, exposed as an escape hatch for git - * subcommands this library does not wrap. Calls still go through `-C `, - * so the working-directory guarantee holds here too. + * Escape hatch for git subcommands this library does not wrap. Calls still go + * through `-C `, so the working-directory guarantee holds here too. */ readonly command: GitCommand; private readonly binary?: string; @@ -73,11 +66,8 @@ export class Git { return this.command.cwd; } - // --- lifecycle ----------------------------------------------------------- - /** - * Initialize a new repository at `dir` and return a handle bound to it. The - * creating command is scoped to `dirname(dir)` (which must already exist), + * The creating command is scoped to `dirname(dir)` (which must already exist), * since `-C ` would fail before `init` runs when `dir` does not yet exist. */ static async init(dir: string, options: InitOptions = {}, backend?: GitBackend): Promise { @@ -94,8 +84,8 @@ export class Git { } /** - * Clone `url` into `dir` and return a handle bound to it. Like {@link init}, - * the creating command is scoped to `dirname(dir)`, which must already exist. + * Like {@link init}, the creating command is scoped to `dirname(dir)`, which + * must already exist. */ static async clone( url: string, @@ -128,9 +118,6 @@ export class Git { return (await this.command.run(["rev-parse", "--show-toplevel"])).trim(); } - // --- inspect ------------------------------------------------------------- - - /** Working-tree and index status, parsed from NUL-terminated porcelain v2. */ async status(): Promise { const out = await this.command.run([ "status", @@ -155,7 +142,6 @@ export class Git { return (await this.command.run(["rev-parse", "HEAD"])).trim(); } - /** Resolve an arbitrary revision to its commit hash (`rev-parse `). */ async revParse(ref: string): Promise { return (await this.command.run(["rev-parse", ref])).trim(); } @@ -169,12 +155,11 @@ export class Git { return parseLog(await this.command.run(args)); } - /** Best common ancestor of two commits (`merge-base`). */ async mergeBase(a: string, b = "HEAD"): Promise { return (await this.command.run(["merge-base", a, b])).trim(); } - /** Raw diff text (`diff`). Returns the working-tree diff unless options narrow it. */ + /** Returns the working-tree diff unless options narrow it. */ async diff(options: DiffOptions = {}): Promise { const range = options.mergeBase && options.range !== undefined @@ -202,7 +187,6 @@ export class Git { return out; } - /** Raw output of `show` for a commit/object. */ async show(ref: string, options: ShowOptions = {}): Promise { const args = ["show"]; if (options.stat) args.push("--stat"); @@ -210,11 +194,9 @@ export class Git { return this.command.run(args); } - // --- stage / commit ------------------------------------------------------ - /** - * Stage paths (`add`). Defaults to staging everything in `cwd` (`.`). Pass - * `{ all: true }` to stage all changes including deletions across the repo (`-A`). + * Defaults to staging everything in `cwd` (`.`). Pass `{ all: true }` to stage + * all changes including deletions across the repo (`-A`). */ async add(paths: string | string[] = ["."], options: AddOptions = {}): Promise { const args = ["add"]; @@ -239,7 +221,6 @@ export class Git { return this.headSha(); } - /** Reset HEAD/index/working tree (`reset`). Path-scoped when `paths` is set. */ async reset(options: ResetOptions = {}): Promise { const args = ["reset"]; if (options.paths !== undefined && options.paths.length > 0) { @@ -252,7 +233,6 @@ export class Git { await this.command.run(args); } - /** Restore working-tree or index paths (`restore`). */ async restore(paths: string | string[], options: RestoreOptions = {}): Promise { const args = ["restore"]; if (options.staged) args.push("--staged"); @@ -262,9 +242,6 @@ export class Git { await this.command.run(args); } - // --- branches ------------------------------------------------------------ - - /** List branches (`branch`), parsed into {@link BranchInfo}. */ async branches(options: ListBranchesOptions = {}): Promise { const args = ["branch", `--format=${BRANCH_FORMAT}`]; if (options.all) args.push("-a"); @@ -306,9 +283,6 @@ export class Git { await this.command.run(["branch", options.force ? "-D" : "-d", name]); } - // --- remotes / sync ------------------------------------------------------ - - /** Fetch from a remote (`fetch`). */ async fetch(options: FetchOptions = {}): Promise { const args = ["fetch"]; if (options.all) args.push("--all"); @@ -317,7 +291,6 @@ export class Git { await this.command.run(args); } - /** Integrate a remote branch (`pull`). */ async pull(options: PullOptions = {}): Promise { const args = ["pull"]; if (options.rebase) args.push("--rebase"); @@ -326,7 +299,6 @@ export class Git { await this.command.run(args); } - /** Publish local commits (`push`). */ async push(options: PushOptions = {}): Promise { const args = ["push"]; if (options.setUpstream) args.push("-u"); @@ -358,7 +330,6 @@ export class Git { return [...map.values()]; } - /** Add a remote (`remote add `). */ async addRemote(name: string, url: string): Promise { await this.command.run(["remote", "add", name, url]); } @@ -393,13 +364,9 @@ export class Git { return parseLsRemote(await command.run(args)); } - // --- worktrees ----------------------------------------------------------- - /** - * Add a worktree at `path` (`worktree add`) and return a `Git` handle bound - * to it — the isolation primitive for giving a task its own working directory. - * This runs from the current repo's `cwd`, so unlike {@link init}/{@link clone} - * no parent-scoping is needed; only the returned handle points at `path`. + * Runs from the current repo's `cwd`, so unlike {@link init}/{@link clone} no + * parent-scoping is needed; only the returned handle points at `path`. */ async worktreeAdd(path: string, options: WorktreeAddOptions = {}): Promise { const args = ["worktree", "add"]; @@ -425,13 +392,10 @@ export class Git { await this.command.run(args); } - /** Prune stale worktree administrative entries (`worktree prune`). */ async worktreePrune(): Promise { await this.command.run(["worktree", "prune"]); } - // --- config -------------------------------------------------------------- - /** * Read a config value (`config --get`). Returns `undefined` when the key is * unset, mirroring git's own "exit 1, no output" convention rather than throwing. @@ -449,7 +413,6 @@ export class Git { return res.stdout.replace(/\n$/, ""); } - /** Set a config value (`config`). Pass `{ global: true }` for `--global`. */ async setConfig(key: string, value: string, scope: ConfigScope = {}): Promise { const args = ["config"]; if (scope.global) args.push("--global"); diff --git a/packages/git-bun/src/types.ts b/packages/git-bun/src/types.ts index e3b16da..cbe94a2 100644 --- a/packages/git-bun/src/types.ts +++ b/packages/git-bun/src/types.ts @@ -1,7 +1,3 @@ -// Typed structs parsed out of git plumbing/porcelain output, plus the options -// objects each Git method accepts. Field names favor the git concept they come -// from (e.g. `shortSha` from `%h`, `ahead`/`behind` from `branch.ab`). - /** A commit as reported by `git log` with a machine-readable `--pretty` format. */ export interface CommitInfo { /** Full 40-char commit hash (`%H`). Usable directly as a ref. */ @@ -68,7 +64,6 @@ export interface WorktreeInfo { export interface BranchInfo { /** Short branch name, e.g. `"main"`. */ name: string; - /** Commit hash the branch points at. */ sha: string; current: boolean; /** Upstream tracking branch (e.g. `"origin/main"`), or `undefined` if none. */ @@ -77,9 +72,7 @@ export interface BranchInfo { /** A configured remote as reported by `git remote -v`. */ export interface RemoteInfo { - /** Remote name, e.g. `"origin"`. */ name: string; - /** URL used for fetching. */ fetchUrl: string; /** URL used for pushing (usually identical to {@link fetchUrl}). */ pushUrl: string; @@ -87,7 +80,6 @@ export interface RemoteInfo { /** A single ref advertised by `git ls-remote`. */ export interface RemoteRef { - /** Commit hash the ref points at. */ sha: string; /** Fully qualified ref name, e.g. `"refs/heads/main"`. */ ref: string; @@ -96,7 +88,6 @@ export interface RemoteRef { /** Mode passed to {@link Git.reset}: how far the reset reaches. */ export type ResetMode = "soft" | "mixed" | "hard"; -/** Options for {@link Git.init}. */ export interface InitOptions { /** Create a bare repository (`--bare`). */ bare?: boolean; @@ -108,7 +99,6 @@ export interface InitOptions { env?: Record; } -/** Options for {@link Git.clone}. */ export interface CloneOptions { /** Branch or tag to check out (`--branch`). */ branch?: string; @@ -124,7 +114,6 @@ export interface CloneOptions { env?: Record; } -/** Options for {@link Git.log}. */ export interface LogOptions { /** Limit to the most recent N commits (`-n`). */ maxCount?: number; @@ -134,7 +123,6 @@ export interface LogOptions { paths?: string[]; } -/** Options for {@link Git.diff}. */ export interface DiffOptions { /** Diff the index against HEAD (`--staged`) instead of the working tree. */ staged?: boolean; @@ -159,19 +147,16 @@ export interface DiffOptions { includeUntracked?: boolean; } -/** Options for {@link Git.show}. */ export interface ShowOptions { /** Emit a diffstat summary (`--stat`) instead of the full patch. */ stat?: boolean; } -/** Options for {@link Git.add}. */ export interface AddOptions { /** Stage all changes including deletions (`-A`), ignoring the `paths` argument. */ all?: boolean; } -/** Options for {@link Git.commit}. */ export interface CommitOptions { /** Automatically stage modified/deleted tracked files before committing (`-a`). */ all?: boolean; @@ -183,7 +168,6 @@ export interface CommitOptions { author?: string; } -/** Options for {@link Git.reset}. */ export interface ResetOptions { /** How far to reset: `"soft"`, `"mixed"` (default), or `"hard"`. */ mode?: ResetMode; @@ -193,7 +177,6 @@ export interface ResetOptions { paths?: string[]; } -/** Options for {@link Git.restore}. */ export interface RestoreOptions { /** Restore the index (`--staged`) rather than the working tree. */ staged?: boolean; @@ -201,7 +184,6 @@ export interface RestoreOptions { source?: string; } -/** Options for {@link Git.branches}. */ export interface ListBranchesOptions { /** Include remote-tracking branches as well as local ones (`-a`). */ all?: boolean; @@ -209,19 +191,16 @@ export interface ListBranchesOptions { remote?: boolean; } -/** Options for {@link Git.createBranch}. */ export interface CreateBranchOptions { /** Commit/branch to start the new branch from. Defaults to HEAD. */ startPoint?: string; } -/** Options for {@link Git.checkout}. */ export interface CheckoutOptions { /** Create the branch before checking it out (`-b`). */ create?: boolean; } -/** Options for {@link Git.switchBranch}. */ export interface SwitchOptions { /** Create the branch before switching to it (`-c`). */ create?: boolean; @@ -234,13 +213,11 @@ export interface SwitchOptions { startPoint?: string; } -/** Options for {@link Git.deleteBranch}. */ export interface DeleteBranchOptions { /** Force deletion of an unmerged branch (`-D` instead of `-d`). */ force?: boolean; } -/** Options for {@link Git.fetch}. */ export interface FetchOptions { /** Remote to fetch from. Defaults to git's default (usually `origin`). */ remote?: string; @@ -250,7 +227,6 @@ export interface FetchOptions { all?: boolean; } -/** Options for {@link Git.pull}. */ export interface PullOptions { /** Rebase local commits onto the fetched head instead of merging (`--rebase`). */ rebase?: boolean; @@ -260,7 +236,6 @@ export interface PullOptions { branch?: string; } -/** Options for {@link Git.push}. */ export interface PushOptions { /** Remote to push to. */ remote?: string; @@ -274,7 +249,6 @@ export interface PushOptions { tags?: boolean; } -/** Options for {@link Git.lsRemote}. */ export interface LsRemoteOptions { /** * Directory to run from; must already exist. `ls-remote` talks to `url`, but @@ -298,7 +272,6 @@ export interface LsRemoteOptions { env?: Record; } -/** Options for {@link Git.worktreeAdd}. */ export interface WorktreeAddOptions { /** Create a new branch for the worktree (`-b `). */ newBranch?: string; @@ -310,13 +283,11 @@ export interface WorktreeAddOptions { commitish?: string; } -/** Options for {@link Git.worktreeRemove}. */ export interface WorktreeRemoveOptions { /** Remove even with uncommitted changes or a locked worktree (`--force`). */ force?: boolean; } -/** Scope for {@link Git.getConfig} / {@link Git.setConfig}. */ export interface ConfigScope { /** Operate on the global (`--global`) config rather than the repository's. */ global?: boolean; diff --git a/packages/tmux-bun/index.test.ts b/packages/tmux-bun/index.test.ts index c39b952..9f55dbc 100644 --- a/packages/tmux-bun/index.test.ts +++ b/packages/tmux-bun/index.test.ts @@ -20,7 +20,6 @@ describe("buildTarget", () => { const NAMESPACE = "tmux-bun-test"; const SOCKET = join(tmpdir(), `tmux-bun-test-${process.pid}.sock`); -// Probe whether tmux exists at all; skip the whole suite gracefully if not. const tmuxAvailable = await (async () => { try { return (await Bun.$`tmux -V`.quiet().nothrow()).exitCode === 0; @@ -208,13 +207,11 @@ suite("namespace isolation from the default socket", () => { } const iso = new Tmux({ namespace: isoNamespace }); - await iso.killServer(); // clean slate + await iso.killServer(); - // The namespaced server sees none of the default sessions... expect(await iso.hasSession(DEFAULT_PROBE)).toBe(false); expect((await iso.listSessions()).map((s) => s.name)).not.toContain(DEFAULT_PROBE); - // ...and a session created in the namespace is invisible on the default socket. await iso.newSession({ name: ISO_ONLY }); const defaultSees = await Bun.$`tmux has-session -t ${ISO_ONLY}`.quiet().nothrow(); expect(defaultSees.exitCode).not.toBe(0); diff --git a/packages/tmux-bun/index.ts b/packages/tmux-bun/index.ts index 8d08506..092bbfe 100644 --- a/packages/tmux-bun/index.ts +++ b/packages/tmux-bun/index.ts @@ -1,10 +1,3 @@ -// tmux-bun: a typed, headless API over the tmux CLI for Bun. -// -// The object-oriented surface (Tmux -> Session -> Window -> Pane) is the primary -// way to drive tmux. The low-level TmuxCommand helper is exported as an escape -// hatch for subcommands this library does not wrap, and TmuxBackend is the seam -// for alternative transports (e.g. a future control-mode backend). - export { Tmux, type TmuxOptions } from "./src/tmux"; export { Session } from "./src/session"; export { Window } from "./src/window"; diff --git a/packages/tmux-bun/src/backend.ts b/packages/tmux-bun/src/backend.ts index b84c77b..d6edef6 100644 --- a/packages/tmux-bun/src/backend.ts +++ b/packages/tmux-bun/src/backend.ts @@ -1,8 +1,3 @@ -// The transport seam. Every tmux invocation in this library flows through a -// TmuxBackend, so a future control-mode (`tmux -C`) backend can be dropped in -// without touching any call site — only this interface must be satisfied. - -/** Raw result of a single `tmux` invocation. */ export interface TmuxRunResult { readonly stdout: string; readonly stderr: string; @@ -10,9 +5,8 @@ export interface TmuxRunResult { } /** - * A transport that runs one tmux command and reports its raw result. The - * `args` it receives already include the namespace socket flags, so a backend - * must never inject its own — it just executes what it is given. + * The `args` a backend receives already include the namespace socket flags, so a + * backend must never inject its own. */ export interface TmuxBackend { run(args: readonly string[]): Promise; diff --git a/packages/tmux-bun/src/command.ts b/packages/tmux-bun/src/command.ts index a3c6aa3..bbc3f6c 100644 --- a/packages/tmux-bun/src/command.ts +++ b/packages/tmux-bun/src/command.ts @@ -1,7 +1,6 @@ import { ShellBackend, type TmuxBackend, type TmuxRunResult } from "./backend"; import { TmuxError } from "./errors"; -/** Construction options shared by {@link TmuxCommand} and the root {@link Tmux}. */ export interface TmuxCommandOptions { /** Server namespace, injected as `-L `. Runs a private tmux server. */ namespace: string; @@ -24,8 +23,7 @@ export interface TmuxCommandOptions { /** * The single choke point through which every tmux command passes. It prepends * the namespace socket flags to every invocation, which is what makes namespace - * isolation a hard guarantee: no higher-level method can construct a call that - * escapes its server, because none of them touch the socket flags at all. + * isolation a hard guarantee: no higher-level method touches the socket flags. */ export class TmuxCommand { readonly namespace: string; diff --git a/packages/tmux-bun/src/format.ts b/packages/tmux-bun/src/format.ts index d37834f..6ea30de 100644 --- a/packages/tmux-bun/src/format.ts +++ b/packages/tmux-bun/src/format.ts @@ -6,15 +6,14 @@ import type { SessionInfo, WindowInfo, PaneInfo } from "./types"; // space, colon, or tab could. export const FIELD_SEP = "\u001f"; -/** Build a `-F` format string from raw tmux variable names, joined by {@link FIELD_SEP}. */ function formatOf(fields: readonly string[]): string { return fields.map((f) => `#{${f}}`).join(FIELD_SEP); } /** - * Split multi-line `-F` output into per-record field maps. Blank lines (the - * trailing newline tmux emits) are dropped. With `noUncheckedIndexedAccess` a - * missing column reads as `undefined`, so downstream coercers tolerate it. + * Blank lines (the trailing newline tmux emits) are dropped. With + * `noUncheckedIndexedAccess` a missing column reads as `undefined`, so + * downstream coercers tolerate it. */ function parseRows(stdout: string, fields: readonly string[]): Array> { return stdout diff --git a/packages/tmux-bun/src/pane.ts b/packages/tmux-bun/src/pane.ts index 304c947..1d91465 100644 --- a/packages/tmux-bun/src/pane.ts +++ b/packages/tmux-bun/src/pane.ts @@ -40,7 +40,6 @@ export class Pane { return res.exitCode === 0 && res.stdout.trim().length > 0; } - /** Split this pane, returning a handle to the newly created pane. */ async split(options: SplitOptions): Promise { const args = ["split-window", "-t", this.target, "-P", "-F", "#{pane_id}"]; // `-h` places the new pane to the side, `-v` below — matching tmux's own flags. diff --git a/packages/tmux-bun/src/session.ts b/packages/tmux-bun/src/session.ts index 62da771..4bb086e 100644 --- a/packages/tmux-bun/src/session.ts +++ b/packages/tmux-bun/src/session.ts @@ -25,7 +25,7 @@ export class Session { return info; } - /** Whether this session exists, via `has-session`. Genuine errors still throw. */ + /** Whether this session exists, via `has-session`. */ async exists(): Promise { const res = await this.cmd.tryRun(["has-session", "-t", this.target]); return res.exitCode === 0; @@ -40,7 +40,6 @@ export class Session { await this.cmd.run(["kill-session", "-t", this.target]); } - /** Create a new window in this session, returning a handle to it. */ async newWindow(options: NewWindowOptions = {}): Promise { // A trailing ":" forces tmux to read the target as a session, not a window — // `new-window -t main` would otherwise look for a window named "main". diff --git a/packages/tmux-bun/src/target.ts b/packages/tmux-bun/src/target.ts index 999c46f..19d9496 100644 --- a/packages/tmux-bun/src/target.ts +++ b/packages/tmux-bun/src/target.ts @@ -1,4 +1,3 @@ -/** Components of a tmux target string, any of which may be omitted. */ export interface TargetParts { /** Session name or id (`$N`). */ session?: string; @@ -12,10 +11,6 @@ export interface TargetParts { * Build a tmux target of the form `session:window.pane`. tmux server-unique ids * (`$N`, `@N`, `%N`) are valid targets on their own, so this is only needed when * addressing entities by name/index; the handle classes accept ids directly. - * - * Examples: `{ session: "build" }` -> `"build"`; - * `{ session: "build", window: "server" }` -> `"build:server"`; - * `{ session: "build", window: 1, pane: 0 }` -> `"build:1.0"`. */ export function buildTarget(parts: TargetParts): string { let target = parts.session ?? ""; diff --git a/packages/tmux-bun/src/tmux.ts b/packages/tmux-bun/src/tmux.ts index f12c89b..83df6c0 100644 --- a/packages/tmux-bun/src/tmux.ts +++ b/packages/tmux-bun/src/tmux.ts @@ -4,7 +4,6 @@ import { SESSION_FORMAT, parseSessions } from "./format"; import { Session } from "./session"; import type { NewSessionOptions, OptionScope, SessionInfo } from "./types"; -/** Construction options for {@link Tmux}. */ export type TmuxOptions = TmuxCommandOptions; // Substrings tmux uses to report an unreachable server. Seeing one of these on a @@ -12,17 +11,15 @@ export type TmuxOptions = TmuxCommandOptions; const NO_SERVER = /no server running|error connecting|No such file/i; /** - * Root handle for a single namespaced tmux server. Everything reachable from a - * `Tmux` instance is confined to its namespace: the underlying - * {@link TmuxCommand} injects `-L ` (or `-S `) into every - * invocation, so no session, window, or pane outside this namespace can be - * listed, touched, or killed. + * Everything reachable from a `Tmux` instance is confined to its namespace: the + * underlying {@link TmuxCommand} injects `-L ` (or `-S `) + * into every invocation, so no session, window, or pane outside this namespace + * can be listed, touched, or killed. */ export class Tmux { /** - * The low-level command helper, exposed as an escape hatch for tmux - * subcommands this library does not wrap. Calls still go through the - * namespace socket flags, so the isolation guarantee holds here too. + * Escape hatch for tmux subcommands this library does not wrap. Calls still go + * through the namespace socket flags, so the isolation guarantee holds here too. */ readonly command: TmuxCommand; @@ -121,7 +118,6 @@ export class Tmux { return value.length === 0 ? undefined : value; } - /** Set an option via `set-option`. Pass {@link OptionScope.global} for `-g`. */ async setOption(name: string, value: string, scope: OptionScope = {}): Promise { const args = ["set-option"]; if (scope.global) args.push("-g"); diff --git a/packages/tmux-bun/src/types.ts b/packages/tmux-bun/src/types.ts index 2088995..f656c05 100644 --- a/packages/tmux-bun/src/types.ts +++ b/packages/tmux-bun/src/types.ts @@ -1,6 +1,3 @@ -// Typed structs parsed out of tmux `-F` format strings. Field names mirror the -// tmux format variables they come from (e.g. `#{session_windows}`). - /** A tmux session as reported by `list-sessions` / `display-message`. */ export interface SessionInfo { /** Server-unique id, e.g. `"$0"`. Usable directly as a `-t` target. */ @@ -46,7 +43,6 @@ export interface PaneInfo { pid: number; } -/** Options for {@link Tmux.newSession}. */ export interface NewSessionOptions { /** Session name (`-s`). tmux assigns a numeric name if omitted. */ name?: string; @@ -60,7 +56,6 @@ export interface NewSessionOptions { height?: number; } -/** Options for {@link Session.newWindow}. */ export interface NewWindowOptions { /** Window name (`-n`). */ name?: string; @@ -75,7 +70,6 @@ export interface NewWindowOptions { /** Direction of a pane split: `"horizontal"` = side by side, `"vertical"` = stacked. */ export type SplitDirection = "horizontal" | "vertical"; -/** Options for {@link Pane.split} / {@link Window.split}. */ export interface SplitOptions { direction: SplitDirection; /** New pane size (`-l`). Interpreted as a percentage when {@link percent} is set. */ @@ -90,10 +84,9 @@ export interface SplitOptions { select?: boolean; } -/** Directional step for {@link Pane.resize}. */ export type ResizeDirection = "left" | "right" | "up" | "down"; -/** Options for {@link Pane.resize}. Directional and absolute forms may be combined. */ +/** Directional and absolute forms may be combined. */ export interface ResizeOptions { /** Resize toward this edge by {@link amount} cells. */ direction?: ResizeDirection; @@ -105,13 +98,11 @@ export interface ResizeOptions { height?: number; } -/** Options for {@link Pane.sendKeys}. */ export interface SendKeysOptions { /** Send a trailing `Enter` after the literal text, submitting the line. */ enter?: boolean; } -/** Options for {@link Pane.capture}. */ export interface CaptureOptions { /** First line to capture (`-S`). Negative values reach into scrollback. */ start?: number; @@ -121,7 +112,6 @@ export interface CaptureOptions { escapes?: boolean; } -/** Options for {@link Pane.run}. */ export interface RunOptions { /** Give up after this many milliseconds. Defaults to 5000. */ timeoutMs?: number; @@ -129,7 +119,6 @@ export interface RunOptions { pollMs?: number; } -/** Target scope for option get/set operations. */ export interface OptionScope { /** Operate on a server/global option (`-g`). */ global?: boolean; diff --git a/packages/webterm/encode.ts b/packages/webterm/encode.ts index 9cc9d06..f3c72fe 100644 --- a/packages/webterm/encode.ts +++ b/packages/webterm/encode.ts @@ -1,9 +1,3 @@ -/** - * encode.ts — turn a bun-vt `Terminal`'s current grid into a `GridMsg` - * snapshot, using the compact per-cell encoding from `protocol.ts`, and diff - * two snapshots into the runs of a `PatchMsg`. - */ - import type { Cell, CellStyle, Color, Terminal } from "bun-vt"; import { ATTR, @@ -51,7 +45,6 @@ export function encodeCell(cell: Cell): WireCell { const u = (UNDERLINE as readonly string[]).indexOf(cell.style.underline); const w = (WIDTH as readonly string[]).indexOf(cell.width); - // The overwhelmingly common case: blank space, default colors, no styling. if (blankGlyph && f === undefined && b === undefined && a === 0 && u <= 0 && w <= 0) { return 0; } @@ -66,11 +59,7 @@ export function encodeCell(cell: Cell): WireCell { return out; } -/** - * Serialize the terminal's whole active screen into a `GridMsg`. `seq` is the - * connection's frame counter, which only the caller streaming the frames knows; - * a one-off snapshot can leave it at the default. - */ +/** `seq` is the streaming caller's frame counter; a one-off snapshot can leave it at the default. */ export function serializeGrid(term: Terminal, seq = 0): GridMsg { const rows = term.rows; const cols = term.cols; @@ -103,7 +92,6 @@ export function serializeGrid(term: Terminal, seq = 0): GridMsg { }; } -/** Structural color comparison — a palette index only ever equals the same index. */ export function colorsEqual(a: WireColor | undefined, b: WireColor | undefined): boolean { if (a === b) return true; if (a === undefined || b === undefined || typeof a === "number" || typeof b === "number") return false; @@ -111,10 +99,10 @@ export function colorsEqual(a: WireColor | undefined, b: WireColor | undefined): } /** - * Structural cell comparison. Deliberately not a `JSON.stringify` comparison: - * key order is an encoder implementation detail, and this runs over every cell - * of every frame. The blank literal `0` only ever equals another `0` — the - * encoder never emits an all-defaults object, so an object is assumed to differ. + * Deliberately not a `JSON.stringify` comparison: key order is an encoder + * implementation detail, and this runs over every cell of every frame. The blank + * literal `0` only ever equals another `0` — the encoder never emits an + * all-defaults object, so an object is assumed to differ. */ function cellsEqual(a: WireCell, b: WireCell): boolean { if (a === b) return true; @@ -130,11 +118,9 @@ function cellsEqual(a: WireCell, b: WireCell): boolean { } /** - * Diff two same-sized snapshots into the runs of a `PatchMsg` — empty when - * nothing changed. Runs rather than per-cell entries because a scroll or a - * repainted status line changes whole spans at once. - * - * Throws on a dimension mismatch; the caller sends a full snapshot instead. + * Runs rather than per-cell entries because a scroll or a repainted status line + * changes whole spans at once. Throws on a dimension mismatch; the caller sends + * a full snapshot instead. */ export function diffGrid(prev: GridMsg, next: GridMsg): PatchRun[] { if (prev.cols !== next.cols || prev.rows !== next.rows) { diff --git a/packages/webterm/index.ts b/packages/webterm/index.ts index 2840d2e..959b79f 100644 --- a/packages/webterm/index.ts +++ b/packages/webterm/index.ts @@ -1,10 +1,4 @@ -/** - * webterm — the JSON-over-WebSocket terminal protocol plus the server-side - * bridge that turns a PTY into streamed grid snapshots. - * - * Import `webterm/protocol` (type-only, browser-safe) from the client; import - * from `webterm` on the server for the bridge + encoder. - */ +/** Server-side entry: pulls in bun-vt. Browser code must import `webterm/protocol` instead. */ export { TerminalBridge, diff --git a/packages/webterm/protocol.ts b/packages/webterm/protocol.ts index fb9ed77..58839e4 100644 --- a/packages/webterm/protocol.ts +++ b/packages/webterm/protocol.ts @@ -1,14 +1,4 @@ -/** - * webterm/protocol.ts — the JSON-over-WebSocket contract between the browser and - * the Bun server. Both sides import from this file. It contains only type - * definitions plus a few pure data tables (no PTY / no VT emulator), so it is - * safe to bundle straight into the browser. - * - * The server is the terminal emulator: it parses the shell's raw VT bytes with - * bun-vt into a cell grid and streams it to the client, either as a full `grid` - * snapshot or as a `patch` delta against the previous frame. The client only - * paints cells and sends keystrokes. - */ +/** Browser-safe by construction: no PTY and no VT emulator may be imported here. */ import { z } from "zod"; @@ -64,10 +54,6 @@ const ClientMsgSchema = z.discriminatedUnion("type", [ ResyncMsgSchema, ]); -// --------------------------------------------------------------------------- -// Client → server -// --------------------------------------------------------------------------- - /** First message: allocate a Terminal and spawn the shell at this size. */ export interface InitMsg { readonly type: "init"; @@ -75,7 +61,6 @@ export interface InitMsg { readonly rows: number; } -/** Keystrokes / paste bytes to write to the PTY. */ export interface InputMsg { readonly type: "input"; readonly data: string; @@ -88,7 +73,6 @@ export interface ResizeMsg { readonly rows: number; } -/** Acknowledge receipt of the server frame with this `seq`. */ export interface AckMsg { readonly type: "ack"; readonly seq: number; @@ -101,13 +85,8 @@ export interface ResyncMsg { export type ClientMsg = InitMsg | InputMsg | ResizeMsg | AckMsg | ResyncMsg; -// --------------------------------------------------------------------------- -// Server → client -// --------------------------------------------------------------------------- - export type WireCursorShape = "block" | "underline" | "bar"; -/** Cursor position and appearance within the active screen. */ export interface WireCursor { readonly x: number; readonly y: number; @@ -118,10 +97,9 @@ export interface WireCursor { } /** - * A full active-screen snapshot to paint. `cells` is indexed `cells[row][col]`. - * - * `seq` counts frames on this connection: it is the anchor a `patch` deltas - * against, so a client that misses a frame can detect the gap and `resync`. + * `cells` is indexed `cells[row][col]`. `seq` counts frames on this connection: + * it is the anchor a `patch` deltas against, so a client that misses a frame can + * detect the gap and `resync`. */ export interface GridMsg { readonly type: "grid"; @@ -156,10 +134,6 @@ export interface ExitMsg { export type ServerMsg = GridMsg | PatchMsg | ExitMsg; -// --------------------------------------------------------------------------- -// Compact cell encoding -// --------------------------------------------------------------------------- - /** * A cell color. * - omitted (the field absent on the cell) → terminal default @@ -276,10 +250,8 @@ export function decodeServerMessage(frame: unknown): ServerMsg { } /** - * Apply a delta to the snapshot it was computed against, returning the new - * snapshot. `prev` is never mutated — the client repaints from the object it - * already holds — and rows no run touches are shared with `prev` rather than - * copied. + * `prev` is never mutated — the client repaints from the object it already holds + * — and rows no run touches are shared with `prev` rather than copied. * * Throws on a dimension mismatch: the patch anchors to a differently sized * frame, and the caller's recovery is to ask for a full `grid`. @@ -308,21 +280,16 @@ export function applyPatch(prev: GridMsg, patch: PatchMsg): GridMsg { export interface GridStreamResult { /** The grid to paint, when this frame produced a new one. */ readonly grid: GridMsg | null; - /** What to send back: an ack for an accepted frame, or a request for a full snapshot. */ readonly reply: AckMsg | ResyncMsg | null; } -/** - * The client half of the frame protocol: holds the current snapshot, folds - * patches into it, and decides what to send back. Both clients drive one of - * these so the sequencing rules live in one place. - */ +/** The client half of the frame protocol; both the web and CLI clients drive one. */ export class GridStream { #grid: GridMsg | null = null; /** - * Set when a frame could not be applied. Only one `resync` goes out per gap: - * re-requesting on every following patch would pile a burst of requests onto - * the congested link that probably caused the gap in the first place. + * Only one `resync` goes out per gap: re-requesting on every following patch + * would pile a burst of requests onto the congested link that probably caused + * the gap in the first place. */ #awaitingSnapshot = false; @@ -338,8 +305,8 @@ export class GridStream { } /** - * Take a server frame. Only frames that were actually applied are acked: an - * ack means "I have this frame", and the server's pacing depends on that. + * Only frames that were actually applied are acked: an ack means "I have this + * frame", and the server's pacing depends on that. */ accept(msg: GridMsg | PatchMsg): GridStreamResult { if (msg.type === "grid") { @@ -397,10 +364,6 @@ export function splitInput(data: string): string[] { return chunks; } -// --------------------------------------------------------------------------- -// Shared index tables (pure data — used by both encoder and renderer) -// --------------------------------------------------------------------------- - /** Bitmask of text-decoration flags for `WireCellObject.a`. */ export const ATTR = { bold: 1, diff --git a/packages/webterm/server.ts b/packages/webterm/server.ts index 3dc6844..13f884d 100644 --- a/packages/webterm/server.ts +++ b/packages/webterm/server.ts @@ -1,20 +1,3 @@ -/** - * server.ts — the server-side terminal bridge. - * - * A `TerminalBridge` owns one PTY subprocess (e.g. `tmux attach ...`) plus one - * bun-vt `Terminal`. Raw bytes from the PTY are fed into the VT parser; - * frames (coalesced to ~60fps) are pushed to a `send` callback. Client - * keystrokes and resizes are forwarded to the PTY. - * - * What to actually put on the wire — a full `grid`, a `patch`, nothing at all, - * or nothing *yet* because the client is behind — is decided by `FrameSequencer`, - * which touches no PTY and no socket so the rules can be tested on their own. - * - * The bridge is transport-agnostic — the caller owns the WebSocket and just - * wires `send` to `ws.send` and dispatches decoded `ClientMsg`s to the methods - * here. - */ - import { Terminal as VtTerminal } from "bun-vt"; import { colorsEqual, diffGrid, serializeGrid } from "./encode"; import type { ClientMsg, GridMsg, PatchMsg, ServerMsg, WireCursor } from "./protocol"; @@ -38,9 +21,9 @@ export interface FrameSequencerOptions { } /** - * Whole-cursor comparison, every field. A cursor move with no cell change is a - * real change the client must be told about, so this is what separates a - * genuinely idle terminal from one where only the caret moved. + * A cursor move with no cell change is a real change the client must be told + * about, so this is what separates a genuinely idle terminal from one where only + * the caret moved. */ function cursorsEqual(a: WireCursor, b: WireCursor): boolean { return ( @@ -54,13 +37,10 @@ function cursorsEqual(a: WireCursor, b: WireCursor): boolean { } /** - * Decides what the next frame on a connection should be: a full `grid`, a - * `patch` against the last frame *actually sent*, or nothing. - * - * Two things bound it. The diff baseline is the last sent frame, never the last - * computed one, so coalescing and pacing stay lossless. And an ack window caps - * how many frames may be in flight: without it the sender never asks whether - * the receiver is keeping up, and a slow link accumulates unbounded lag. + * The diff baseline is the last frame *actually sent*, never the last computed + * one, so coalescing and pacing stay lossless. The ack window caps how many + * frames may be in flight: without it the sender never asks whether the receiver + * is keeping up, and a slow link accumulates unbounded lag. */ export class FrameSequencer { private readonly maxUnackedFrames: number; @@ -83,9 +63,8 @@ export class FrameSequencer { } /** - * Decide what to send for the terminal's current state. `grid` is whatever - * `serializeGrid` produced; its `seq` is ignored and restamped here, so the - * caller never has to thread a frame counter through the encoder. + * The incoming `grid`'s `seq` is ignored and restamped here, so the caller + * never has to thread a frame counter through the encoder. */ next(grid: GridMsg): FrameDecision { const now = this.now(); @@ -145,7 +124,6 @@ export class FrameSequencer { export interface TerminalBridgeOptions extends FrameSequencerOptions { /** argv for the PTY process, e.g. `["tmux", "-L", "fleet-ship", "attach", "-t", name]`. */ readonly argv: string[]; - /** Sink for server→client messages (grid snapshots, patches, exit). */ readonly send: (msg: ServerMsg) => void; /** Frame coalescing interval in ms. Default ~16 (60fps). */ readonly frameIntervalMs?: number; @@ -206,7 +184,6 @@ export class TerminalBridge { this.proc?.terminal?.write(data); } - /** Resize both the PTY and the VT parser, then repaint. */ resize(cols: number, rows: number): void { if (this.stopped) return; this.proc?.terminal?.resize(cols, rows); @@ -263,11 +240,10 @@ export class TerminalBridge { } /** - * Coalesce a burst of PTY output into one decision per interval. A `blocked` - * decision deliberately leaves the timer disarmed: re-arming it would spin - * against a client that is already behind, so the next `ack` restarts the - * stream instead (and, while blocked, further PTY output re-arms it, which is - * also when the sequencer's ack timeout gets re-examined). + * A `blocked` decision deliberately leaves the timer disarmed: re-arming it + * would spin against a client that is already behind, so the next `ack` + * restarts the stream instead (and, while blocked, further PTY output re-arms + * it, which is also when the sequencer's ack timeout gets re-examined). */ private scheduleFrame(): void { if (this.frameTimer !== null || this.stopped) return; From 5bc3c0a11c5928aa8a577247637cbdb1a22b1633 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 2 Aug 2026 16:39:25 -0500 Subject: [PATCH 3/6] Consolidate duplicated code and drop single-use indirection Package-local reductions across seven workspaces: - Store's ship and repo halves collapse onto one JsonCollection; the shared SerialQueue keeps both serialized against each other, and load() still commits both maps or neither. - One SerialQueue, one isStrictDescendant, one armory record reader/writer, one GitHub header builder, one sha256 schema, one provider list. - ReposRoute and ShipsRoute share a RegistryPage shell; five copies of the async-submit modal body become useSubmitAction. - Row-shift, row-blank, and CSI/DCS sequence construction extracted in bun-vt. - Un-export symbols with no external consumer; inline single-use aliases, pass-throughs, and injection points nothing supplies. Restore four invariants that the comment pass deleted with their header blocks, as one-line notes on the declarations that enforce them. Typecheck clean and 746 tests pass, unchanged throughout. Co-Authored-By: Claude Opus 5 (1M context) --- packages/bun-vt/src/parser.ts | 28 +-- packages/bun-vt/src/screen.ts | 58 ++--- packages/bun-vt/src/sgr.ts | 28 +-- .../fleet-bridge/src/armory/armory-service.ts | 16 +- packages/fleet-bridge/src/fleet-manager.ts | 10 +- packages/fleet-bridge/src/providers/github.ts | 21 +- .../fleet-bridge/src/providers/provider.ts | 5 +- packages/fleet-bridge/src/serial-queue.ts | 13 + packages/fleet-bridge/src/store/store.ts | 235 ++++++++++-------- .../src/components/CreateWorkspaceModal.tsx | 22 +- .../src/components/RegistryPage.tsx | 94 +++++++ .../src/components/SwitchBranchModal.tsx | 20 +- packages/fleet-client/src/index.ts | 8 +- .../fleet-client/src/lib/useSubmitAction.ts | 22 ++ .../fleet-client/src/routes/ReposRoute.tsx | 139 +++-------- .../fleet-client/src/routes/ShipsRoute.tsx | 124 +++------ packages/fleet-protocol/index.ts | 6 +- packages/fleet-protocol/src/armory.ts | 8 +- packages/fleet-protocol/src/identifier.ts | 4 +- .../fleet-ship/src/armory/armory-cache.ts | 22 +- .../fleet-ship/src/armory/armory-installer.ts | 64 ++--- packages/fleet-ship/src/armory/armory-sync.ts | 2 +- .../fleet-ship/src/armory/dotfile-linker.ts | 66 ++--- packages/fleet-ship/src/armory/record-file.ts | 42 ++++ packages/fleet-ship/src/contained-path.ts | 7 +- packages/fleet-ship/src/managed-fs.ts | 14 +- packages/fleet-ship/src/plugin-command.ts | 10 +- packages/fleet-ship/src/plugin-installer.ts | 4 +- packages/fleet-ship/src/providers.ts | 2 +- packages/fleet-ship/src/skill-installer.ts | 4 +- packages/fleet-ship/src/workspace-manager.ts | 13 +- packages/webterm/index.ts | 6 - 32 files changed, 524 insertions(+), 593 deletions(-) create mode 100644 packages/fleet-bridge/src/serial-queue.ts create mode 100644 packages/fleet-client/src/components/RegistryPage.tsx create mode 100644 packages/fleet-client/src/lib/useSubmitAction.ts create mode 100644 packages/fleet-ship/src/armory/record-file.ts diff --git a/packages/bun-vt/src/parser.ts b/packages/bun-vt/src/parser.ts index 7b162a4..04a6f84 100644 --- a/packages/bun-vt/src/parser.ts +++ b/packages/bun-vt/src/parser.ts @@ -341,17 +341,19 @@ export class Parser { } } + #sequence(final: number): CsiSequence { + return { + params: this.#params.slice(), + colon: this.#colon.slice(), + intermediates: this.#intermediates, + prefix: this.#prefix, + final: String.fromCharCode(final), + }; + } + #csiDispatch(final: number): void { this.#commitParam(); - if (!this.#overflow) { - this.h.csiDispatch({ - params: this.#params.slice(), - colon: this.#colon.slice(), - intermediates: this.#intermediates, - prefix: this.#prefix, - final: String.fromCharCode(final), - }); - } + if (!this.#overflow) this.h.csiDispatch(this.#sequence(final)); this.#state = S.GROUND; this.#clear(); } @@ -423,13 +425,7 @@ export class Parser { this.#state = S.DCS_IGNORE; return; } - this.h.dcsHook?.({ - params: this.#params.slice(), - colon: this.#colon.slice(), - intermediates: this.#intermediates, - prefix: this.#prefix, - final: String.fromCharCode(final), - }); + this.h.dcsHook?.(this.#sequence(final)); this.#state = S.DCS_PASSTHROUGH; } diff --git a/packages/bun-vt/src/screen.ts b/packages/bun-vt/src/screen.ts index 4ef3180..7616df6 100644 --- a/packages/bun-vt/src/screen.ts +++ b/packages/bun-vt/src/screen.ts @@ -58,7 +58,6 @@ export class Screen { tabStops: boolean[]; #saved: SavedCursor | null = null; - #altGrid: Row[] | null = null; #savedForAlt: SavedCursor | null = null; constructor(cols: number, rows: number, maxScrollback: number) { @@ -260,12 +259,7 @@ export class Screen { if (intoScrollback) this.#pushScrollback(leaving); } - for (let y = top; y <= bottom - count; y++) { - this.grid[y] = this.grid[y + count]!; - } - for (let y = bottom - count + 1; y <= bottom; y++) { - this.grid[y] = this.#blankRow(); - } + this.#shiftRowsUp(top, bottom, count); } /** Scroll the scroll region down by `n` lines (content moves down). */ @@ -275,6 +269,19 @@ export class Screen { const count = Math.min(n, bottom - top + 1); if (count <= 0) return; + this.#shiftRowsDown(top, bottom, count); + } + + #shiftRowsUp(top: number, bottom: number, count: number): void { + for (let y = top; y <= bottom - count; y++) { + this.grid[y] = this.grid[y + count]!; + } + for (let y = bottom - count + 1; y <= bottom; y++) { + this.grid[y] = this.#blankRow(); + } + } + + #shiftRowsDown(top: number, bottom: number, count: number): void { for (let y = bottom; y >= top + count; y--) { this.grid[y] = this.grid[y - count]!; } @@ -307,12 +314,7 @@ export class Screen { if (this.cursor.y < this.scrollTop || this.cursor.y > this.scrollBottom) return; const bottom = this.scrollBottom; const count = Math.min(n, bottom - this.cursor.y + 1); - for (let y = bottom; y >= this.cursor.y + count; y--) { - this.grid[y] = this.grid[y - count]!; - } - for (let y = this.cursor.y; y < this.cursor.y + count; y++) { - this.grid[y] = this.#blankRow(); - } + this.#shiftRowsDown(this.cursor.y, bottom, count); this.cursor.x = 0; this.cursor.pendingWrap = false; } @@ -321,12 +323,7 @@ export class Screen { if (this.cursor.y < this.scrollTop || this.cursor.y > this.scrollBottom) return; const bottom = this.scrollBottom; const count = Math.min(n, bottom - this.cursor.y + 1); - for (let y = this.cursor.y; y <= bottom - count; y++) { - this.grid[y] = this.grid[y + count]!; - } - for (let y = bottom - count + 1; y <= bottom; y++) { - this.grid[y] = this.#blankRow(); - } + this.#shiftRowsUp(this.cursor.y, bottom, count); this.cursor.x = 0; this.cursor.pendingWrap = false; } @@ -370,25 +367,25 @@ export class Screen { this.cursor.pendingWrap = false; } + #blankRows(from: number, to: number): void { + for (let y = from; y < to; y++) { + for (const c of this.grid[y]!) this.#blank(c); + } + } + eraseDisplay(mode: number): void { if (mode === 2 || mode === 3) { - for (let y = 0; y < this.rows; y++) { - for (const c of this.grid[y]!) this.#blank(c); - } + this.#blankRows(0, this.rows); if (mode === 3) this.scrollback = []; this.cursor.pendingWrap = false; return; } if (mode === 0) { this.eraseLine(0); - for (let y = this.cursor.y + 1; y < this.rows; y++) { - for (const c of this.grid[y]!) this.#blank(c); - } + this.#blankRows(this.cursor.y + 1, this.rows); } else if (mode === 1) { this.eraseLine(1); - for (let y = 0; y < this.cursor.y; y++) { - for (const c of this.grid[y]!) this.#blank(c); - } + this.#blankRows(0, this.cursor.y); } this.cursor.pendingWrap = false; } @@ -458,9 +455,9 @@ export class Screen { enterAlt(saveCursor: boolean, clear: boolean): void { if (this.onAlt) return; if (saveCursor) this.#savedForAlt = this.#snapshotCursor(); - this.#altGrid = Array.from({ length: this.rows }, () => this.#blankRow()); + const altGrid = Array.from({ length: this.rows }, () => this.#blankRow()); this.#primaryGrid = this.grid; - this.grid = this.#altGrid; + this.grid = altGrid; this.onAlt = true; if (clear) this.eraseDisplay(2); if (saveCursor) { @@ -475,7 +472,6 @@ export class Screen { if (!this.onAlt) return; this.grid = this.#primaryGrid!; this.#primaryGrid = null; - this.#altGrid = null; this.onAlt = false; if (restoreCursor) this.#applyCursor(this.#savedForAlt); this.#savedForAlt = null; diff --git a/packages/bun-vt/src/sgr.ts b/packages/bun-vt/src/sgr.ts index 819a892..838f2bc 100644 --- a/packages/bun-vt/src/sgr.ts +++ b/packages/bun-vt/src/sgr.ts @@ -5,21 +5,17 @@ import { type Pen } from "./cell"; import { DEFAULT_COLOR, palette, rgb, NamedColor } from "./color"; -interface Group { - readonly parts: readonly number[]; -} - -function toGroups(params: readonly number[], colon: readonly boolean[]): Group[] { - const groups: Group[] = []; +function toGroups(params: readonly number[], colon: readonly boolean[]): number[][] { + const groups: number[][] = []; let cur: number[] = []; for (let i = 0; i < params.length; i++) { if (i > 0 && !colon[i]) { - groups.push({ parts: cur }); + groups.push(cur); cur = []; } cur.push(params[i]!); } - if (cur.length > 0 || params.length === 0) groups.push({ parts: cur }); + if (cur.length > 0 || params.length === 0) groups.push(cur); return groups; } @@ -41,13 +37,13 @@ function colorFromSubParams(sub: readonly number[]) { export function applySgr(pen: Pen, params: readonly number[], colon: readonly boolean[]): void { const groups = toGroups(params, colon); // An empty SGR (`CSI m`) means reset. - if (groups.length === 1 && groups[0]!.parts.length === 0) { + if (groups.length === 1 && groups[0]!.length === 0) { pen.resetAttributes(); return; } for (let gi = 0; gi < groups.length; gi++) { - const parts = groups[gi]!.parts; + const parts = groups[gi]!; const code = parts.length === 0 ? 0 : parts[0]!; switch (code) { @@ -157,7 +153,7 @@ function clampUnderline(n: number): number { * spreads them across the following single-value groups. */ function readExtendedColor( - groups: readonly Group[], + groups: readonly (readonly number[])[], gi: number, parts: readonly number[], ): { color: ReturnType | null; nextGi: number } { @@ -166,15 +162,15 @@ function readExtendedColor( return { color: colorFromSubParams(parts.slice(1)), nextGi: gi }; } // Semicolon form: pull following groups as flat values. - const type = groups[gi + 1]?.parts[0]; + const type = groups[gi + 1]?.[0]; if (type === 5) { - const idx = groups[gi + 2]?.parts[0]; + const idx = groups[gi + 2]?.[0]; return { color: idx != null ? palette(idx) : null, nextGi: gi + 2 }; } if (type === 2) { - const r = groups[gi + 2]?.parts[0]; - const g = groups[gi + 3]?.parts[0]; - const b = groups[gi + 4]?.parts[0]; + const r = groups[gi + 2]?.[0]; + const g = groups[gi + 3]?.[0]; + const b = groups[gi + 4]?.[0]; if (r != null && g != null && b != null) { return { color: rgb(r, g, b), nextGi: gi + 4 }; } diff --git a/packages/fleet-bridge/src/armory/armory-service.ts b/packages/fleet-bridge/src/armory/armory-service.ts index 0ef99c6..4f9cb70 100644 --- a/packages/fleet-bridge/src/armory/armory-service.ts +++ b/packages/fleet-bridge/src/armory/armory-service.ts @@ -12,9 +12,10 @@ import { type ArmorySection, type DotfileMap, } from "fleet-protocol"; +import { SerialQueue } from "../serial-queue"; /** Ceiling on a single `readFile`; oversized files are still listed in the manifest. */ -export const MAX_ARMORY_FILE_BYTES = 10 * 1024 * 1024; +const MAX_ARMORY_FILE_BYTES = 10 * 1024 * 1024; const IGNORED_NAMES = new Set([".git", ".DS_Store"]); @@ -60,24 +61,15 @@ export class ArmoryMapError extends Error { export class ArmoryService { private cached: ArmoryManifest | undefined; - private queue: Promise = Promise.resolve(); + private readonly queue = new SerialQueue(); private readonly root: string; constructor(armoryDirectory: string) { this.root = resolve(armoryDirectory); } - private serialized(operation: () => Promise | T): Promise { - const result = this.queue.then(operation, operation); - this.queue = result.then( - () => undefined, - () => undefined, - ); - return result; - } - async manifest(): Promise { - return this.serialized(async () => { + return this.queue.run(async () => { if (this.cached) return this.cached; this.cached = await this.scan(); return this.cached; diff --git a/packages/fleet-bridge/src/fleet-manager.ts b/packages/fleet-bridge/src/fleet-manager.ts index e8a07bb..d7010bf 100644 --- a/packages/fleet-bridge/src/fleet-manager.ts +++ b/packages/fleet-bridge/src/fleet-manager.ts @@ -289,7 +289,9 @@ export class FleetManager { /** `POST /repos` — register a repo. `provider` defaults to `"custom"`. */ async addRepo(input: CreateRepoInput): Promise { - const parsed = this.parseInput(CreateRepoInputSchema, input, "repo"); + const result = CreateRepoInputSchema.safeParse(input); + if (!result.success) throw new BridgeError("invalid repo", 400); + const parsed = result.data; try { return await this.store.createRepo({ name: parsed.name, @@ -879,12 +881,6 @@ export class FleetManager { } } - private parseInput(schema: { safeParse(value: unknown): { success: true; data: T } | { success: false } }, value: unknown, label: string): T { - const result = schema.safeParse(value); - if (!result.success) throw new BridgeError(`invalid ${label}`, 400); - return result.data; - } - private async persist(): Promise { await this.store.replaceAllShips( [...this.connections.values()].map((conn) => ({ name: conn.name, url: conn.url })), diff --git a/packages/fleet-bridge/src/providers/github.ts b/packages/fleet-bridge/src/providers/github.ts index 2521ef2..0565b52 100644 --- a/packages/fleet-bridge/src/providers/github.ts +++ b/packages/fleet-bridge/src/providers/github.ts @@ -336,10 +336,7 @@ export class GitHubProvider implements RepoProvider { } } - private async request( - path: string, - init?: { method?: string; body?: unknown }, - ): Promise { + private baseHeaders(): Record { const headers: Record = { Accept: "application/vnd.github+json", "User-Agent": "fleet-bridge", @@ -347,6 +344,14 @@ export class GitHubProvider implements RepoProvider { if (this.token) { headers.Authorization = `Bearer ${this.token}`; } + return headers; + } + + private async request( + path: string, + init?: { method?: string; body?: unknown }, + ): Promise { + const headers = this.baseHeaders(); if (init?.body !== undefined) { headers["Content-Type"] = "application/json"; } @@ -371,13 +376,7 @@ export class GitHubProvider implements RepoProvider { * auth headers at all. */ private async downloadText(path: string): Promise { - const headers: Record = { - Accept: "application/vnd.github+json", - "User-Agent": "fleet-bridge", - }; - if (this.token) { - headers.Authorization = `Bearer ${this.token}`; - } + const headers = this.baseHeaders(); let response = await this.fetchImpl(`${this.baseUrl}${path}`, { headers, redirect: "manual" }); diff --git a/packages/fleet-bridge/src/providers/provider.ts b/packages/fleet-bridge/src/providers/provider.ts index 81028f3..7252c9a 100644 --- a/packages/fleet-bridge/src/providers/provider.ts +++ b/packages/fleet-bridge/src/providers/provider.ts @@ -1,4 +1,7 @@ -/** An HTTP-shaped provider failure; `status` is the status a route should surface. */ +/** + * An HTTP-shaped provider failure; `status` is the status a route should surface. + * Mirrors — but deliberately never imports — the manager's `BridgeError`, so the provider layer stays free of the HTTP framework. + */ export class ProviderError extends Error { readonly status: number; diff --git a/packages/fleet-bridge/src/serial-queue.ts b/packages/fleet-bridge/src/serial-queue.ts new file mode 100644 index 0000000..5d3301d --- /dev/null +++ b/packages/fleet-bridge/src/serial-queue.ts @@ -0,0 +1,13 @@ +export class SerialQueue { + private tail: Promise = Promise.resolve(); + + /** `.then(operation, operation)` on both arms so a rejected predecessor still lets the next operation run. */ + run(operation: () => Promise | T): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/packages/fleet-bridge/src/store/store.ts b/packages/fleet-bridge/src/store/store.ts index 64bc364..1ad6b89 100644 --- a/packages/fleet-bridge/src/store/store.ts +++ b/packages/fleet-bridge/src/store/store.ts @@ -1,6 +1,8 @@ import { lstat, open, rename, unlink } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { FleetIdentifierSchema, RepoSchema, ShipSchema, type Repo, type Ship } from "fleet-protocol"; +import type { z } from "zod"; +import { SerialQueue } from "../serial-queue"; type Persist = (target: string, contents: string) => Promise; @@ -11,76 +13,130 @@ export class RepoAlreadyExistsError extends Error { } } -export class Store { - private ships = new Map(); - private repos = new Map(); - private loaded = false; - private queue: Promise = Promise.resolve(); - private readonly persist: Persist; +class JsonCollection { + private map = new Map(); constructor( - private readonly dataDirectory: string, - deps?: { persist?: Persist }, - ) { - this.persist = deps?.persist ?? atomicWrite; + private readonly queue: SerialQueue, + private readonly schema: z.ZodType, + private readonly target: string, + private readonly persist: Persist, + ) {} + + async read(): Promise> { + const items = this.schema.array().parse(await readJsonArray(this.target)); + return new Map(items.map((item) => [item.name, item])); } - private serialized(operation: () => Promise | T): Promise { - const result = this.queue.then(operation, operation); - this.queue = result.then( - () => undefined, - () => undefined, - ); - return result; + adopt(map: Map): void { + this.map = map; } - async load(): Promise { - return this.serialized(async () => { - if (this.loaded) return; - const ships = ShipSchema.array().parse(await this.readFile("ships.json")); - const repos = RepoSchema.array().parse(await this.readFile("repos.json")); - this.ships = new Map(ships.map((ship) => [ship.name, ship])); - this.repos = new Map(repos.map((repo) => [repo.name, repo])); - this.loaded = true; + getAll(): Promise { + return this.queue.run(() => [...this.map.values()]); + } + + get(name: string): Promise { + return this.queue.run(() => this.map.get(name)); + } + + put(item: T, guard?: (current: ReadonlyMap) => void): Promise { + const parsed = this.schema.parse(item); + return this.queue.run(async () => { + guard?.(this.map); + const next = new Map(this.map).set(parsed.name, parsed); + await this.write(next); + this.map = next; + return parsed; }); } - private async readFile(name: string): Promise { - const target = join(this.dataDirectory, name); - try { - const info = await lstat(target); - if (!info.isFile()) throw new Error(`refusing to read non-file store path: ${target}`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; - throw error; - } - return (await Bun.file(target).json()) as T[]; + update(name: string, values: Partial>): Promise { + FleetIdentifierSchema.parse(name); + return this.queue.run(async () => { + const existing = this.map.get(name); + if (!existing) return undefined; + const updated = this.schema.parse({ ...existing, ...values, name }); + const next = new Map(this.map).set(name, updated); + await this.write(next); + this.map = next; + return updated; + }); } - private persistShips(ships: Map): Promise { - return this.persist(join(this.dataDirectory, "ships.json"), stringify([...ships.values()])); + delete(name: string): Promise { + FleetIdentifierSchema.parse(name); + return this.queue.run(async () => { + const existing = this.map.get(name); + if (!existing) return undefined; + const next = new Map(this.map); + next.delete(name); + await this.write(next); + this.map = next; + return existing; + }); + } + + replaceAll(items: T[]): Promise { + const parsed = this.schema.array().parse(items); + return this.queue.run(async () => { + const next = new Map(parsed.map((item) => [item.name, item])); + await this.write(next); + this.map = next; + }); + } + + private write(map: Map): Promise { + return this.persist(this.target, stringify([...map.values()])); + } +} + +export class Store { + private loaded = false; + private readonly queue = new SerialQueue(); + private readonly shipCollection: JsonCollection; + private readonly repoCollection: JsonCollection; + + constructor( + dataDirectory: string, + deps?: { persist?: Persist }, + ) { + const persist = deps?.persist ?? atomicWrite; + this.shipCollection = new JsonCollection( + this.queue, + ShipSchema, + join(dataDirectory, "ships.json"), + persist, + ); + this.repoCollection = new JsonCollection( + this.queue, + RepoSchema, + join(dataDirectory, "repos.json"), + persist, + ); } - private persistRepos(repos: Map): Promise { - return this.persist(join(this.dataDirectory, "repos.json"), stringify([...repos.values()])); + async load(): Promise { + return this.queue.run(async () => { + if (this.loaded) return; + const ships = await this.shipCollection.read(); + const repos = await this.repoCollection.read(); + this.shipCollection.adopt(ships); + this.repoCollection.adopt(repos); + this.loaded = true; + }); } async getAllShips(): Promise { - return this.serialized(() => [...this.ships.values()]); + return this.shipCollection.getAll(); } async getShip(name: string): Promise { - return this.serialized(() => this.ships.get(name)); + return this.shipCollection.get(name); } async createShip(ship: Ship): Promise { - ship = ShipSchema.parse(ship); - return this.serialized(async () => { - const ships = new Map(this.ships).set(ship.name, ship); - await this.persistShips(ships); - this.ships = ships; - return ship; - }); + return this.shipCollection.put(ship); } async upsertShip(ship: Ship): Promise { @@ -88,94 +144,53 @@ export class Store { } async updateShip(name: string, values: Partial>): Promise { - FleetIdentifierSchema.parse(name); - return this.serialized(async () => { - const existing = this.ships.get(name); - if (!existing) return undefined; - const updated = ShipSchema.parse({ ...existing, ...values, name }); - const ships = new Map(this.ships).set(name, updated); - await this.persistShips(ships); - this.ships = ships; - return updated; - }); + return this.shipCollection.update(name, values); } async deleteShip(name: string): Promise { - FleetIdentifierSchema.parse(name); - return this.serialized(async () => { - const existing = this.ships.get(name); - if (!existing) return undefined; - const ships = new Map(this.ships); - ships.delete(name); - await this.persistShips(ships); - this.ships = ships; - return existing; - }); + return this.shipCollection.delete(name); } async replaceAllShips(ships: Ship[]): Promise { - ships = ShipSchema.array().parse(ships); - return this.serialized(async () => { - const replacement = new Map(ships.map((ship) => [ship.name, ship])); - await this.persistShips(replacement); - this.ships = replacement; - }); + return this.shipCollection.replaceAll(ships); } async getAllRepos(): Promise { - return this.serialized(() => [...this.repos.values()]); + return this.repoCollection.getAll(); } async getRepo(name: string): Promise { - return this.serialized(() => this.repos.get(name)); + return this.repoCollection.get(name); } async createRepo(repo: Repo): Promise { - repo = RepoSchema.parse(repo); - return this.serialized(async () => { - if (this.repos.has(repo.name)) throw new RepoAlreadyExistsError(repo.name); - const repos = new Map(this.repos).set(repo.name, repo); - await this.persistRepos(repos); - this.repos = repos; - return repo; + return this.repoCollection.put(repo, (current) => { + if (current.has(repo.name)) throw new RepoAlreadyExistsError(repo.name); }); } async upsertRepo(repo: Repo): Promise { - repo = RepoSchema.parse(repo); - return this.serialized(async () => { - const repos = new Map(this.repos).set(repo.name, repo); - await this.persistRepos(repos); - this.repos = repos; - return repo; - }); + return this.repoCollection.put(repo); } async updateRepo(name: string, values: Partial>): Promise { - FleetIdentifierSchema.parse(name); - return this.serialized(async () => { - const existing = this.repos.get(name); - if (!existing) return undefined; - const updated = RepoSchema.parse({ ...existing, ...values, name }); - const repos = new Map(this.repos).set(name, updated); - await this.persistRepos(repos); - this.repos = repos; - return updated; - }); + return this.repoCollection.update(name, values); } async deleteRepo(name: string): Promise { - FleetIdentifierSchema.parse(name); - return this.serialized(async () => { - const existing = this.repos.get(name); - if (!existing) return undefined; - const repos = new Map(this.repos); - repos.delete(name); - await this.persistRepos(repos); - this.repos = repos; - return existing; - }); + return this.repoCollection.delete(name); + } +} + +async function readJsonArray(target: string): Promise { + try { + const info = await lstat(target); + if (!info.isFile()) throw new Error(`refusing to read non-file store path: ${target}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; } + return (await Bun.file(target).json()) as unknown[]; } async function atomicWrite(target: string, contents: string): Promise { diff --git a/packages/fleet-client/src/components/CreateWorkspaceModal.tsx b/packages/fleet-client/src/components/CreateWorkspaceModal.tsx index 8f000b8..f176435 100644 --- a/packages/fleet-client/src/components/CreateWorkspaceModal.tsx +++ b/packages/fleet-client/src/components/CreateWorkspaceModal.tsx @@ -1,8 +1,9 @@ -import { useState, type FormEvent } from "react"; +import { useState } from "react"; import { useFleet } from "@/data/FleetContext"; import { Modal } from "@/components/ui/modal"; import { Input } from "@/components/ui/input"; import { Field, ModalActions } from "@/routes/ReposRoute"; +import { useSubmitAction } from "@/lib/useSubmitAction"; interface Props { repoName: string; @@ -16,24 +17,13 @@ export function CreateWorkspaceModal({ repoName, ship, onClose }: Props) { const [name, setName] = useState(""); const [branch, setBranch] = useState("main"); const [selectedShip, setSelectedShip] = useState(ship ?? ships[0]?.name ?? ""); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); const shipName = ship ?? selectedShip; - const submit = async (e: FormEvent) => { - e.preventDefault(); - setPending(true); - setError(null); - try { - await createWorkspace({ ship: shipName, repoName, name: name.trim(), branch: branch.trim() }); - onClose(); - } catch (err) { - setError((err as Error).message); - } finally { - setPending(false); - } - }; + const { error, pending, submit } = useSubmitAction( + () => createWorkspace({ ship: shipName, repoName, name: name.trim(), branch: branch.trim() }), + onClose, + ); return ( diff --git a/packages/fleet-client/src/components/RegistryPage.tsx b/packages/fleet-client/src/components/RegistryPage.tsx new file mode 100644 index 0000000..3e8a7a5 --- /dev/null +++ b/packages/fleet-client/src/components/RegistryPage.tsx @@ -0,0 +1,94 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { Trash2 } from "lucide-react"; + +export const COLS = "1fr 1.6fr 110px 34px"; + +export function RegistryPage({ + glyph, + title, + blurb, + newLabel, + onNew, + columns, + cols, + empty, + rows, + rowKey, + onDelete, + renderRow, + children, +}: { + glyph: string; + title: string; + blurb: string; + newLabel: string; + onNew: () => void; + columns: ReactNode; + cols: string; + empty: string; + rows: readonly T[]; + rowKey: (row: T) => string; + onDelete: (row: T) => void; + renderRow: (row: T) => ReactNode; + children?: ReactNode; +}) { + return ( +
+ + ← bridge + + +
+
+

{`${glyph} ${title}`}

+

{blurb}

+
+ +
+ +
+
+ {columns} + +
+ + {rows.length === 0 && ( +
{empty}
+ )} + + {rows.map((row) => { + const key = rowKey(row); + return ( +
+ {renderRow(row)} + +
+ ); + })} +
+ + {children} +
+ ); +} diff --git a/packages/fleet-client/src/components/SwitchBranchModal.tsx b/packages/fleet-client/src/components/SwitchBranchModal.tsx index 7201fe3..19990c1 100644 --- a/packages/fleet-client/src/components/SwitchBranchModal.tsx +++ b/packages/fleet-client/src/components/SwitchBranchModal.tsx @@ -1,8 +1,9 @@ -import { useState, type FormEvent } from "react"; +import { useState } from "react"; import { useFleet } from "@/data/FleetContext"; import { Modal } from "@/components/ui/modal"; import { Input } from "@/components/ui/input"; import { Field, ModalActions } from "@/routes/ReposRoute"; +import { useSubmitAction } from "@/lib/useSubmitAction"; interface Props { repo: string; @@ -14,22 +15,7 @@ interface Props { export function SwitchBranchModal({ repo, name, currentBranch, onClose }: Props) { const { switchBranch } = useFleet(); const [branch, setBranch] = useState(currentBranch); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - - const submit = async (e: FormEvent) => { - e.preventDefault(); - setPending(true); - setError(null); - try { - await switchBranch(repo, name, branch.trim()); - onClose(); - } catch (err) { - setError((err as Error).message); - } finally { - setPending(false); - } - }; + const { error, pending, submit } = useSubmitAction(() => switchBranch(repo, name, branch.trim()), onClose); const target = branch.trim(); diff --git a/packages/fleet-client/src/index.ts b/packages/fleet-client/src/index.ts index 821dd69..7687d15 100644 --- a/packages/fleet-client/src/index.ts +++ b/packages/fleet-client/src/index.ts @@ -80,11 +80,7 @@ export function upgradeBridgeWebSocket( return new Response("Upgrade failed", { status: 500 }); } -export function startClientServer( - bridgeUrl: string, - port?: number, - deps?: { createWebSocket?: CreateWebSocket }, -) { +export function startClientServer(bridgeUrl: string, port?: number) { const bridgeWSUrl = bridgeUrl.replace(/^http/, "ws"); /** Strip the `/bridge` prefix, preserving path + query (defaults to `/`). */ @@ -103,7 +99,7 @@ export function startClientServer( const path = bridgePath(url); if (req.headers.get("upgrade") === "websocket") { - return upgradeBridgeWebSocket(req, server, bridgeWSUrl + path, deps?.createWebSocket); + return upgradeBridgeWebSocket(req, server, bridgeWSUrl + path); } const target = bridgeUrl + path; diff --git a/packages/fleet-client/src/lib/useSubmitAction.ts b/packages/fleet-client/src/lib/useSubmitAction.ts new file mode 100644 index 0000000..6d8fbef --- /dev/null +++ b/packages/fleet-client/src/lib/useSubmitAction.ts @@ -0,0 +1,22 @@ +import { useState, type FormEvent } from "react"; + +export function useSubmitAction(action: () => Promise, onClose: () => void) { + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + const submit = async (e?: FormEvent) => { + e?.preventDefault(); + setPending(true); + setError(null); + try { + await action(); + onClose(); + } catch (err) { + setError((err as Error).message); + } finally { + setPending(false); + } + }; + + return { error, pending, submit }; +} diff --git a/packages/fleet-client/src/routes/ReposRoute.tsx b/packages/fleet-client/src/routes/ReposRoute.tsx index a27525e..8f2a989 100644 --- a/packages/fleet-client/src/routes/ReposRoute.tsx +++ b/packages/fleet-client/src/routes/ReposRoute.tsx @@ -1,11 +1,9 @@ -import { useState, type FormEvent, type ReactNode } from "react"; -import { Link } from "react-router-dom"; -import { Trash2 } from "lucide-react"; +import { useState, type ReactNode } from "react"; import { useFleet } from "@/data/FleetContext"; import { Modal } from "@/components/ui/modal"; import { Input } from "@/components/ui/input"; - -const COLS = "1fr 1.6fr 110px 34px"; +import { COLS, RegistryPage } from "@/components/RegistryPage"; +import { useSubmitAction } from "@/lib/useSubmitAction"; export function ReposRoute() { const { repos, createRepo, deleteRepo } = useFleet(); @@ -13,71 +11,38 @@ export function ReposRoute() { const [pendingDelete, setPendingDelete] = useState(null); return ( -
- - ← bridge - - -
-
-

▣ Repos

-

- Repos the fleet can create workspaces from. -

-
- -
- -
-
+ setCreating(true)} + cols={COLS} + columns={ + <> NAME URL PROVIDER - -
- - {repos.length === 0 && ( -
- No repos registered yet. -
- )} - - {repos.map((r) => ( -
- ▣ {r.name} - - URL - {r.url} - - - PROVIDER - {r.provider} - - -
- ))} -
- + + } + empty="No repos registered yet." + rows={repos} + rowKey={(r) => r.name} + onDelete={(r) => setPendingDelete(r.name)} + renderRow={(r) => ( + <> + ▣ {r.name} + + URL + {r.url} + + + PROVIDER + {r.provider} + + + )} + > {creating && setCreating(false)} onCreate={createRepo} />} {pendingDelete && ( deleteRepo(pendingDelete)} /> )} -
+ ); } @@ -101,22 +66,10 @@ function CreateRepoModal({ const [name, setName] = useState(""); const [url, setUrl] = useState(""); const [provider, setProvider] = useState("github"); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - - const submit = async (e: FormEvent) => { - e.preventDefault(); - setPending(true); - setError(null); - try { - await onCreate({ name: name.trim(), url: url.trim(), provider: provider.trim() || undefined }); - onClose(); - } catch (err) { - setError((err as Error).message); - } finally { - setPending(false); - } - }; + const { error, pending, submit } = useSubmitAction( + () => onCreate({ name: name.trim(), url: url.trim(), provider: provider.trim() || undefined }), + onClose, + ); return ( @@ -153,21 +106,7 @@ export function ConfirmDeleteModal({ onClose: () => void; onConfirm: () => Promise; }) { - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - - const confirm = async () => { - setPending(true); - setError(null); - try { - await onConfirm(); - onClose(); - } catch (err) { - setError((err as Error).message); - } finally { - setPending(false); - } - }; + const { error, pending, submit } = useSubmitAction(onConfirm, onClose); return ( @@ -185,7 +124,7 @@ export function ConfirmDeleteModal({ -
- -
-
+ setCreating(true)} + cols={COLS} + columns={ + <> NAME SPEC STATUS - -
- - {ships.length === 0 && ( -
- No ships registered yet. -
- )} - - {ships.map((s) => ( -
- ▦ {s.name} - - SPEC - {s.spec} - - - STATUS - - {s.status} - - -
- ))} -
- + + } + empty="No ships registered yet." + rows={ships} + rowKey={(s) => s.name} + onDelete={(s) => setPendingDelete(s.name)} + renderRow={(s) => ( + <> + ▦ {s.name} + + SPEC + {s.spec} + + + STATUS + + {s.status} + + + )} + > {creating && setCreating(false)} onCreate={createShip} />} {pendingDelete && ( deleteShip(pendingDelete)} /> )} -
+ ); } @@ -104,22 +69,7 @@ function CreateShipModal({ onCreate: (url: string) => Promise; }) { const [url, setUrl] = useState(""); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - - const submit = async (e: FormEvent) => { - e.preventDefault(); - setPending(true); - setError(null); - try { - await onCreate(url.trim()); - onClose(); - } catch (err) { - setError((err as Error).message); - } finally { - setPending(false); - } - }; + const { error, pending, submit } = useSubmitAction(() => onCreate(url.trim()), onClose); return ( diff --git a/packages/fleet-protocol/index.ts b/packages/fleet-protocol/index.ts index 53dbb25..758d939 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -1,8 +1,4 @@ -export { - FleetIdentifierSchema, - parseFleetIdentifier, - type FleetIdentifier, -} from "./src/identifier"; +export { FleetIdentifierSchema, parseFleetIdentifier } from "./src/identifier"; export { DEFAULT_PORT, ATLAS_FILENAME, diff --git a/packages/fleet-protocol/src/armory.ts b/packages/fleet-protocol/src/armory.ts index 8390139..4f6cf6f 100644 --- a/packages/fleet-protocol/src/armory.ts +++ b/packages/fleet-protocol/src/armory.ts @@ -44,12 +44,14 @@ function isSafeDotfileDestination(destination: string): boolean { return destination.startsWith("~/") || destination.startsWith("/"); } +const Sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"); + const ArmoryFileFactsSchema = z.object({ /** POSIX-separated, relative to the armory root, e.g. `skills/my-skill/SKILL.md`. */ path: z.string().min(1).refine(isSafeArmoryPath, "must be a safe armory-relative path"), section: z.enum(ARMORY_SECTIONS), size: z.number().int().nonnegative(), - sha256: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), + sha256: Sha256Hex, /** Normalized to `0o755` (executable) or `0o644`; host mode bits never leak. */ mode: z.number().int(), }); @@ -102,7 +104,7 @@ export type DotfileMap = z.infer; export const ArmoryManifestSchema = z.object({ /** Content address of the whole armory. */ - revision: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), + revision: Sha256Hex, /** Every scanned file, sorted by `path`. */ entries: ArmoryEntrySchema.array(), dotfileMap: DotfileMapSchema, @@ -129,7 +131,7 @@ export type ArmoryFile = z.infer; */ export const ArmorySyncRequestSchema = z.object({ bridgeUrl: z.url(), - revision: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), + revision: Sha256Hex, }); export type ArmorySyncRequest = z.infer; diff --git a/packages/fleet-protocol/src/identifier.ts b/packages/fleet-protocol/src/identifier.ts index de0557c..260a8af 100644 --- a/packages/fleet-protocol/src/identifier.ts +++ b/packages/fleet-protocol/src/identifier.ts @@ -11,8 +11,6 @@ export const FleetIdentifierSchema = z .refine((value) => !/\p{Cc}/u.test(value), "must not contain Unicode control characters") .refine((value) => !/\p{Cs}/u.test(value), "must be well-formed Unicode"); -export type FleetIdentifier = z.infer; - -export function parseFleetIdentifier(value: unknown): FleetIdentifier { +export function parseFleetIdentifier(value: unknown): string { return FleetIdentifierSchema.parse(value); } diff --git a/packages/fleet-ship/src/armory/armory-cache.ts b/packages/fleet-ship/src/armory/armory-cache.ts index 464288e..251a013 100644 --- a/packages/fleet-ship/src/armory/armory-cache.ts +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -1,6 +1,6 @@ import { chmod, lstat, mkdir, readdir, rename, rm, rmdir } from "node:fs/promises"; import { homedir } from "node:os"; -import { join, relative, resolve, sep } from "node:path"; +import { join, resolve } from "node:path"; import { z } from "zod"; import { ArmoryEntrySchema, @@ -16,6 +16,7 @@ import { type ArmorySyncState, type DotfileMap, } from "fleet-protocol"; +import { isStrictDescendant } from "../contained-path"; // Under HOME, not the ship's `fleetDirectory`: `WorkspaceManager` enumerates // every top-level directory there as a candidate repo, and HOME is the root the @@ -113,7 +114,6 @@ export class ArmoryCache { readonly homeDirectory: string; /** The cache root, so an installer can find `files/` without recomputing it. */ readonly cacheDirectory: string; - private readonly root: string; private readonly filesRoot: string; private readonly statePath: string; private readonly fetchImpl: typeof fetch; @@ -122,10 +122,9 @@ export class ArmoryCache { constructor(options?: { homeDirectory?: string; fetch?: typeof fetch; bridgeUrl?: string }) { this.homeDirectory = resolve(options?.homeDirectory ?? homedir()); - this.root = armoryCacheDirectory(this.homeDirectory); - this.cacheDirectory = this.root; - this.filesRoot = join(this.root, "files"); - this.statePath = join(this.root, "state.json"); + this.cacheDirectory = armoryCacheDirectory(this.homeDirectory); + this.filesRoot = join(this.cacheDirectory, "files"); + this.statePath = join(this.cacheDirectory, "state.json"); this.fetchImpl = options?.fetch ?? fetch; this.configuredBridgeUrl = options?.bridgeUrl; } @@ -252,6 +251,8 @@ export class ArmoryCache { } await mkdir(this.filesRoot, { recursive: true }); + // Uncaught on purpose: one bad file fails the whole sync, so a half-applied + // armory is never recorded under a revision that promises all of it. for (const entry of manifest.entries) await this.materialize(base, entry); await this.prune(new Set(manifest.entries.map((entry) => entry.path))); return applied; @@ -372,12 +373,12 @@ export class ArmoryCache { } private async readState(): Promise { - return readCachedState(this.root); + return readCachedState(this.cacheDirectory); } /** Written last and atomically: a crash mid-sync leaves the old state, so the next sync redoes the work. */ private async writeState(state: CachedState): Promise { - await mkdir(this.root, { recursive: true }); + await mkdir(this.cacheDirectory, { recursive: true }); await atomicWrite(this.statePath, new TextEncoder().encode(`${JSON.stringify(state, null, 2)}\n`), 0o600); } } @@ -467,11 +468,6 @@ async function hashFile(target: string): Promise { return hasher.digest("hex"); } -function isStrictDescendant(root: string, target: string): boolean { - const within = relative(root, target); - return within !== "" && !within.startsWith("..") && !within.startsWith(sep) && !/^[A-Za-z]:/.test(within); -} - /** Zod's default rendering is a JSON blob; this keeps the message readable in an HTTP body. */ function formatIssues(error: z.ZodError): string { return error.issues diff --git a/packages/fleet-ship/src/armory/armory-installer.ts b/packages/fleet-ship/src/armory/armory-installer.ts index c5cb823..6cd73f0 100644 --- a/packages/fleet-ship/src/armory/armory-installer.ts +++ b/packages/fleet-ship/src/armory/armory-installer.ts @@ -1,6 +1,6 @@ -import { lstat, readdir, rename, rm, rmdir } from "node:fs/promises"; +import { lstat, readdir, rmdir } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, relative, resolve, sep } from "node:path"; +import { dirname, join, resolve, sep } from "node:path"; import { z } from "zod"; import { isSafeArmoryPath } from "fleet-protocol"; import { @@ -16,7 +16,9 @@ import { skillRootsFor, type Provider, } from "../providers"; +import { isStrictDescendant } from "../contained-path"; import { armoryCacheDirectory, cachedDotfileMap } from "./armory-cache"; +import { readOwnershipRecord, writeRecordAtomically } from "./record-file"; import { linkDotfiles, type DotfileLink } from "./dotfile-linker"; export type ArmoryInstallOptions = { @@ -49,6 +51,8 @@ const InstalledRecordSchema = z.object({ type InstalledEntry = z.infer["files"][number]; +const InstalledFilesSchema = InstalledRecordSchema.transform((record) => record.files); + type PresentProvider = { provider: Provider; configRoot: string; skillRoots: string[] }; type PlannedFile = { @@ -92,13 +96,19 @@ export async function installArmory( ].sort((a, b) => a.destination.localeCompare(b.destination)); const plannedPaths = new Set(planned.map((file) => file.destination)); - const stale = (await readInstalledRecord(cacheRoot, report.warnings)).filter( - (entry) => !plannedPaths.has(entry.path), - ); + const stale = ( + await readOwnershipRecord( + installedRecordPath(cacheRoot), + InstalledFilesSchema, + report.warnings, + "previously installed files", + ) + ).filter((entry) => !plannedPaths.has(entry.path)); const installed: InstalledEntry[] = []; const failures: Error[] = []; + // Every write into the home directory goes through this session, never directly. await withManagedFiles(homeDirectory, async (session) => { const ensured = new Set(); for (const file of planned) { @@ -159,7 +169,7 @@ export async function installArmory( for (const path of report.removed) { await pruneEmptyDirectories(homeDirectory, boundaryFor(homeDirectory, path), path); } - await writeInstalledRecord(cacheRoot, installed); + await writeRecordAtomically(installedRecordPath(cacheRoot), { version: 1, files: installed }); // Third phase, after the copied files: the map comes from the cache's own // state, so a ship installs exactly the map that arrived with the revision it @@ -337,48 +347,6 @@ async function pruneEmptyDirectories( } } -function isStrictDescendant(root: string, target: string): boolean { - const within = relative(root, target); - return within !== "" && !within.startsWith("..") && !within.startsWith(sep); -} - function installedRecordPath(cacheRoot: string): string { return join(cacheRoot, "installed.json"); } - -async function readInstalledRecord( - cacheRoot: string, - warnings: string[], -): Promise { - const path = installedRecordPath(cacheRoot); - let parsed: unknown; - try { - parsed = await Bun.file(path).json(); - } catch (error) { - // A first run has no record; a corrupt one must not block installing, but - // it does mean this run cannot uninstall what the last one wrote. - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - warnings.push(`ignored unreadable ${path}: previously installed files cannot be removed`); - } - return []; - } - const record = InstalledRecordSchema.safeParse(parsed); - if (!record.success) { - warnings.push(`ignored invalid ${path}: previously installed files cannot be removed`); - return []; - } - return record.data.files; -} - -async function writeInstalledRecord(cacheRoot: string, files: InstalledEntry[]): Promise { - const path = installedRecordPath(cacheRoot); - const body = `${JSON.stringify({ version: 1, files }, null, 2)}\n`; - const temporary = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`; - try { - await Bun.write(temporary, body); - await rename(temporary, path); - } catch (error) { - await rm(temporary, { force: true }).catch(() => undefined); - throw error; - } -} diff --git a/packages/fleet-ship/src/armory/armory-sync.ts b/packages/fleet-ship/src/armory/armory-sync.ts index a98c886..ec6ffa3 100644 --- a/packages/fleet-ship/src/armory/armory-sync.ts +++ b/packages/fleet-ship/src/armory/armory-sync.ts @@ -33,7 +33,7 @@ export async function syncAndInstall( return cache.recordInstall(summarize(report)); } -export function summarize(report: ArmoryInstallReport): ArmoryInstallSummary { +function summarize(report: ArmoryInstallReport): ArmoryInstallSummary { return { skillCount: report.skills.length, pluginCount: report.plugins.length, diff --git a/packages/fleet-ship/src/armory/dotfile-linker.ts b/packages/fleet-ship/src/armory/dotfile-linker.ts index 4e40041..b34dc34 100644 --- a/packages/fleet-ship/src/armory/dotfile-linker.ts +++ b/packages/fleet-ship/src/armory/dotfile-linker.ts @@ -1,14 +1,9 @@ -// Fleet refuses to touch symlinks everywhere else (see managed-fs.ts, which -// manages regular files by content hash); this module is the sole exception, -// and only for links it can prove point inside the cache's `files/dotfiles/`. -// Do not route dotfiles through managed-fs, and do not weaken its checks to -// make that possible. The map is untrusted network input, so a destination must -// resolve strictly inside this ship's home directory. - import { lstat, mkdir, readlink, rename, rm, symlink, unlink } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { z } from "zod"; import { isSafeArmoryPath, type DotfileMap } from "fleet-protocol"; +import { isStrictDescendant } from "../contained-path"; +import { readOwnershipRecord, writeRecordAtomically } from "./record-file"; export type DotfileLinkStatus = "linked" | "unchanged" | "relinked" | "conflict" | "skipped"; @@ -45,10 +40,14 @@ const OwnedLinksSchema = z.object({ type OwnedLink = z.infer["links"][number]; +const OwnedLinksArraySchema = OwnedLinksSchema.transform((record) => record.links); + type PlannedLink = { source: string; target: string; sourcePath: string }; type Placement = { status: Exclude; detail?: string }; +// The sole place Fleet creates symlinks: managed-fs refuses them outright, and +// dotfiles must not be routed through it to make this work. export async function linkDotfiles(options: LinkDotfilesOptions): Promise { const homeDirectory = resolve(options.homeDirectory); const cacheRoot = resolve(options.cacheDirectory); @@ -81,7 +80,13 @@ export async function linkDotfiles(options: LinkDotfilesOptions): Promise 0) throw new AggregateError(failures, "Failed to link the armory dotfiles"); return report; @@ -126,6 +131,7 @@ async function plan( skip(target, `"${source}" is not a safe path under dotfiles/`); continue; } + // The map is untrusted network input, so never weaken this check. if (!isStrictDescendant(homeDirectory, target)) { skip(target, `destination "${destination}" is outside ${homeDirectory}`); continue; @@ -228,43 +234,3 @@ async function entry(path: string) { function ownedLinksPath(cacheRoot: string): string { return join(cacheRoot, "dotfiles.json"); } - -async function readOwnedLinks(cacheRoot: string, warnings: string[]): Promise { - const path = ownedLinksPath(cacheRoot); - let parsed: unknown; - try { - parsed = await Bun.file(path).json(); - } catch (error) { - // A first run has no record; an unreadable one must not block linking, but - // it does mean this run cannot undo what the last one linked. - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - warnings.push(`ignored unreadable ${path}: previously linked dotfiles cannot be removed`); - } - return []; - } - const record = OwnedLinksSchema.safeParse(parsed); - if (!record.success) { - warnings.push(`ignored invalid ${path}: previously linked dotfiles cannot be removed`); - return []; - } - return record.data.links; -} - -async function writeOwnedLinks(cacheRoot: string, links: OwnedLink[]): Promise { - const path = ownedLinksPath(cacheRoot); - const body = `${JSON.stringify({ version: 1, links }, null, 2)}\n`; - const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`; - try { - await mkdir(cacheRoot, { recursive: true }); - await Bun.write(temporary, body); - await rename(temporary, path); - } catch (error) { - await rm(temporary, { force: true }).catch(() => undefined); - throw error; - } -} - -function isStrictDescendant(root: string, target: string): boolean { - const within = relative(root, target); - return within !== "" && !within.startsWith("..") && !within.startsWith(sep) && !isAbsolute(within); -} diff --git a/packages/fleet-ship/src/armory/record-file.ts b/packages/fleet-ship/src/armory/record-file.ts new file mode 100644 index 0000000..b5ac431 --- /dev/null +++ b/packages/fleet-ship/src/armory/record-file.ts @@ -0,0 +1,42 @@ +import { mkdir, rename, rm } from "node:fs/promises"; +import { dirname } from "node:path"; +import type { ZodType } from "zod"; + +export async function readOwnershipRecord( + path: string, + schema: ZodType, + warnings: string[], + subject: string, +): Promise { + let parsed: unknown; + try { + parsed = await Bun.file(path).json(); + } catch (error) { + // A first run has no record; an unusable one must not block this run, but it + // does mean this run cannot undo what the last one did. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + warnings.push(`ignored unreadable ${path}: ${subject} cannot be removed`); + } + return []; + } + const record = schema.safeParse(parsed); + if (!record.success) { + warnings.push(`ignored invalid ${path}: ${subject} cannot be removed`); + return []; + } + return record.data; +} + +/** Written through a temporary sibling, so a crash leaves the previous record intact. */ +export async function writeRecordAtomically(path: string, value: unknown): Promise { + const body = `${JSON.stringify(value, null, 2)}\n`; + const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`; + try { + await mkdir(dirname(path), { recursive: true }); + await Bun.write(temporary, body); + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} diff --git a/packages/fleet-ship/src/contained-path.ts b/packages/fleet-ship/src/contained-path.ts index 76c176f..21c8407 100644 --- a/packages/fleet-ship/src/contained-path.ts +++ b/packages/fleet-ship/src/contained-path.ts @@ -12,6 +12,11 @@ function assertDescendant(root: string, path: string): void { } } +export function isStrictDescendant(root: string, target: string): boolean { + const within = relative(root, target); + return within !== "" && !within.startsWith("..") && !within.startsWith(sep) && !isAbsolute(within); +} + export function containedPath(root: string, ...components: string[]): string { for (const component of components) parseFleetIdentifier(component); const path = resolve(root, ...components); @@ -37,7 +42,7 @@ export function existingRepoPath(root: string, repoName: string): Promise { +async function ensureRepoPath(root: string, repoName: string): Promise { const repo = containedPath(root, repoName); try { await mkdir(repo); diff --git a/packages/fleet-ship/src/managed-fs.ts b/packages/fleet-ship/src/managed-fs.ts index 7cb525d..21033e8 100644 --- a/packages/fleet-ship/src/managed-fs.ts +++ b/packages/fleet-ship/src/managed-fs.ts @@ -112,7 +112,7 @@ type FileSnapshot = { }; type ParentIdentity = { path: string; dev: number | bigint; ino: number | bigint }; -export function isMissing(error: unknown): boolean { +function isMissing(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === "ENOENT"; } @@ -809,10 +809,6 @@ function transitionEntry(transition: ManifestTransition): ManifestEntry { }; } -function desiredMatches(snapshot: FileSnapshot | undefined, hash: string, mode?: number): boolean { - return snapshotMatches(snapshot, hash, mode); -} - /** Serialize one manifest read-modify-write session in-process and across Fleet processes. */ export async function withManagedFiles( homeDirectory: string, @@ -924,7 +920,7 @@ export async function withManagedFiles( } else if (!snapshotMatches(current, recorded.sha256, recorded.mode)) { if (!ownership.force) return "conflict"; status = "updated"; - } else if (desiredMatches(current, desiredHash, desiredMode)) { + } else if (snapshotMatches(current, desiredHash, desiredMode)) { if (recorded.mode !== desiredMode) { manifest.files[normalized] = { provider: ownership.provider, @@ -1061,17 +1057,17 @@ export async function inspectManagedFile( const transition = manifest.transitions[normalized]; if (transition) { if (snapshotMatches(current, transition.intendedSha256, transition.intendedMode)) { - return desiredMatches(current, sha256(bytes(contents)), mode) ? "current" : "outdated-owned"; + return snapshotMatches(current, sha256(bytes(contents)), mode) ? "current" : "outdated-owned"; } if (!snapshotMatches(current, transition.previousSha256, transition.previousMode)) { return "conflict-unmanaged"; } - return desiredMatches(current, sha256(bytes(contents)), mode) ? "current" : "outdated-owned"; + return snapshotMatches(current, sha256(bytes(contents)), mode) ? "current" : "outdated-owned"; } const recorded = manifest.files[normalized]; if (!recorded) return "conflict-unmanaged"; if (!snapshotMatches(current, recorded.sha256, recorded.mode)) return "conflict-unmanaged"; - return desiredMatches(current, sha256(bytes(contents)), mode) ? "current" : "outdated-owned"; + return snapshotMatches(current, sha256(bytes(contents)), mode) ? "current" : "outdated-owned"; } /** Exposed for focused manifest tests and diagnostics. */ diff --git a/packages/fleet-ship/src/plugin-command.ts b/packages/fleet-ship/src/plugin-command.ts index 9b133c4..76fda39 100644 --- a/packages/fleet-ship/src/plugin-command.ts +++ b/packages/fleet-ship/src/plugin-command.ts @@ -13,11 +13,7 @@ import { type PluginStatus, } from "./plugin-installer"; import type { PresenceState } from "./managed-fs"; - -/** Providers a user may pass to `install`; codex has a skill but no plugin. */ -const PROVIDERS = ["claude-code", "opencode", "copilot", "codex"] as const; - -const DISPLAY_ORDER = PROVIDERS; +import { PROVIDERS } from "./providers"; /** The command each provider's harness is invoked as — what a skill/plugin is useless without. */ const PROVIDER_CLI: Record<(typeof PROVIDERS)[number], string> = { @@ -82,7 +78,7 @@ export async function performPluginInstall( } /** Locate each provider's CLI on PATH. Impure (reads PATH); the formatter takes the result. */ -export function inspectProviderClis(): CliStatus[] { +function inspectProviderClis(): CliStatus[] { return PROVIDERS.map((provider) => { const binary = PROVIDER_CLI[provider]; return { provider, binary, path: Bun.which(binary) }; @@ -114,7 +110,7 @@ export function formatDoctorReport( ): string { const lines: string[] = ["fleet-agent skill & plugin status", ""]; - for (const provider of DISPLAY_ORDER) { + for (const provider of PROVIDERS) { lines.push(provider); const cli = clis.find((entry) => entry.provider === provider); diff --git a/packages/fleet-ship/src/plugin-installer.ts b/packages/fleet-ship/src/plugin-installer.ts index f636e3f..71e2a74 100644 --- a/packages/fleet-ship/src/plugin-installer.ts +++ b/packages/fleet-ship/src/plugin-installer.ts @@ -45,8 +45,6 @@ export type InstallFleetPluginOptions = { force?: boolean; }; -export type InspectFleetPluginOptions = InstallFleetPluginOptions; - type FileMapping = { contents: () => Promise; destination: string; @@ -262,7 +260,7 @@ export async function installFleetPlugin( * hook-trust step, so it cannot be installed unattended — see docs/codex.md. */ export async function inspectFleetPlugin( - options: InspectFleetPluginOptions = {}, + options: InstallFleetPluginOptions = {}, ): Promise { const homeDirectory = options.homeDirectory ?? homedir(); const pluginsDirectory = options.pluginsDirectory; diff --git a/packages/fleet-ship/src/providers.ts b/packages/fleet-ship/src/providers.ts index cf6223d..f2719d2 100644 --- a/packages/fleet-ship/src/providers.ts +++ b/packages/fleet-ship/src/providers.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; export type Provider = "claude-code" | "opencode" | "copilot" | "codex"; -export const PROVIDERS: readonly Provider[] = ["claude-code", "opencode", "copilot", "codex"]; +export const PROVIDERS = ["claude-code", "opencode", "copilot", "codex"] as const satisfies readonly Provider[]; export function isProvider(value: string): value is Provider { return (PROVIDERS as readonly string[]).includes(value); diff --git a/packages/fleet-ship/src/skill-installer.ts b/packages/fleet-ship/src/skill-installer.ts index 27d65c2..1fadac5 100644 --- a/packages/fleet-ship/src/skill-installer.ts +++ b/packages/fleet-ship/src/skill-installer.ts @@ -38,8 +38,6 @@ export type InstallFleetSkillOptions = { force?: boolean; }; -export type InspectFleetSkillOptions = InstallFleetSkillOptions; - type ProviderPaths = { provider: Provider; configRoot: string; @@ -133,7 +131,7 @@ export async function installFleetSkill( /** Codex contributes two rows (native + shared `~/.agents`). */ export async function inspectFleetSkill( - options: InspectFleetSkillOptions = {}, + options: InstallFleetSkillOptions = {}, ): Promise { const homeDirectory = options.homeDirectory ?? homedir(); const source = options.sourcePath ? await Bun.file(options.sourcePath).text() : embeddedSkill; diff --git a/packages/fleet-ship/src/workspace-manager.ts b/packages/fleet-ship/src/workspace-manager.ts index 5897f21..df20ace 100644 --- a/packages/fleet-ship/src/workspace-manager.ts +++ b/packages/fleet-ship/src/workspace-manager.ts @@ -42,8 +42,6 @@ export class WorkspaceError extends Error { } } -export type CreateWorkspaceOptions = CreateWorkspaceRequest; - export interface SwitchBranchOptions { readonly branch: string; } @@ -197,16 +195,11 @@ export class WorkspaceManager { /** Whether the workspace directory exists on disk (is a git working tree). */ async has(repoName: string, name: string): Promise { - this.validateIdentifiers(repoName, name); try { - const dir = await existingWorkspacePath(this.config.fleetDirectory, repoName, name); - const gitStat = await lstat(containedPath(dir, ".git")); - if (!gitStat.isDirectory()) throw new ContainedPathError(`git metadata is not a directory: ${dir}/.git`); - await existingWorkspacePath(this.config.fleetDirectory, repoName, name); + await this.requireWorkspace(repoName, name); return true; } catch (error) { - if (error instanceof ContainedPathError) throw new WorkspaceError(error.message, 400); - if (["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; + if (error instanceof WorkspaceError && error.status === 404) return false; throw error; } } @@ -409,7 +402,7 @@ export class WorkspaceManager { return status; } - async create(options: CreateWorkspaceOptions): Promise { + async create(options: CreateWorkspaceRequest): Promise { const parsed = CreateWorkspaceRequestSchema.safeParse(options); if (!parsed.success) throw new WorkspaceError("invalid workspace create request", 400); const { url, repoName, name } = parsed.data; diff --git a/packages/webterm/index.ts b/packages/webterm/index.ts index 959b79f..6b08697 100644 --- a/packages/webterm/index.ts +++ b/packages/webterm/index.ts @@ -11,12 +11,6 @@ export { serializeGrid, encodeCell, diffGrid } from "./encode"; export { ATTR, - UNDERLINE, - WIDTH, - MIN_TERMINAL_COLS, - MAX_TERMINAL_COLS, - MIN_TERMINAL_ROWS, - MAX_TERMINAL_ROWS, MAX_INPUT_BYTES, MAX_PENDING_BYTES, MAX_CLIENT_FRAME_BYTES, From f6b43e6837a464c5b10a7c6fd9bf60e0a7a78013 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 2 Aug 2026 16:46:53 -0500 Subject: [PATCH 4/6] Extract the shared cores of the twin packages apps/cli and apps/fagent carried a copy-pasted client layer; git-bun and tmux-bun a copy-pasted command base. Each pair now shares one package. - fleet-cli-kit holds normalizeUrl, the bridge client, renderTable, and unwrap, which takes the program name as a parameter rather than baking in the message prefix. The seven normalizeUrl tests move with it, so fagent's previously untested copy is now covered. - cli-bun holds RunResult, Backend, CliError, toInt, and an abstract CliCommand whose only abstract member is globalArgs(). GitError and TmuxError still extend it, so fleet-ship's catch-by-name keeps working. ShellBackend stays separate per package: sharing it would have added an env parameter to tmux's public constructor, which never had one. Typecheck clean; 751 tests pass, up from 746 by the five new tests pinning the shared command base. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/package.json | 1 + apps/cli/src/attach.ts | 7 +- apps/cli/src/client.ts | 59 ---------- apps/cli/src/format.ts | 13 +-- apps/cli/src/index.ts | 39 +++---- apps/cli/src/launch-command.ts | 2 +- apps/fagent/package.json | 1 + apps/fagent/src/format.ts | 16 +-- apps/fagent/src/repo-command.ts | 22 ++-- bun.lock | 34 ++++++ packages/cli-bun/.gitignore | 34 ++++++ packages/cli-bun/CLAUDE.md | 106 ++++++++++++++++++ packages/cli-bun/index.test.ts | 81 +++++++++++++ packages/cli-bun/index.ts | 7 ++ packages/cli-bun/package.json | 19 ++++ packages/cli-bun/src/backend.ts | 10 ++ packages/cli-bun/src/command.ts | 44 ++++++++ packages/cli-bun/src/errors.ts | 25 +++++ packages/cli-bun/src/parse.ts | 4 + packages/cli-bun/tsconfig.json | 30 +++++ packages/fleet-cli-kit/index.ts | 8 ++ packages/fleet-cli-kit/package.json | 23 ++++ .../fleet-cli-kit}/src/client.ts | 17 +-- packages/fleet-cli-kit/src/format.ts | 11 ++ .../fleet-cli-kit}/tests/client.test.ts | 0 packages/fleet-cli-kit/tsconfig.json | 30 +++++ packages/git-bun/package.json | 3 + packages/git-bun/src/backend.ts | 12 +- packages/git-bun/src/command.ts | 33 ++---- packages/git-bun/src/errors.ts | 17 +-- packages/git-bun/src/format.ts | 6 +- packages/tmux-bun/package.json | 3 + packages/tmux-bun/src/backend.ts | 12 +- packages/tmux-bun/src/command.ts | 33 ++---- packages/tmux-bun/src/errors.ts | 17 +-- packages/tmux-bun/src/format.ts | 6 +- 36 files changed, 553 insertions(+), 232 deletions(-) create mode 100644 packages/cli-bun/.gitignore create mode 100644 packages/cli-bun/CLAUDE.md create mode 100644 packages/cli-bun/index.test.ts create mode 100644 packages/cli-bun/index.ts create mode 100644 packages/cli-bun/package.json create mode 100644 packages/cli-bun/src/backend.ts create mode 100644 packages/cli-bun/src/command.ts create mode 100644 packages/cli-bun/src/errors.ts create mode 100644 packages/cli-bun/src/parse.ts create mode 100644 packages/cli-bun/tsconfig.json create mode 100644 packages/fleet-cli-kit/index.ts create mode 100644 packages/fleet-cli-kit/package.json rename {apps/fagent => packages/fleet-cli-kit}/src/client.ts (66%) create mode 100644 packages/fleet-cli-kit/src/format.ts rename {apps/cli => packages/fleet-cli-kit}/tests/client.test.ts (100%) create mode 100644 packages/fleet-cli-kit/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index e437e85..c33322f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -16,6 +16,7 @@ "commander": "latest", "elysia": "latest", "fleet-bridge": "workspace:*", + "fleet-cli-kit": "workspace:*", "fleet-client": "workspace:*", "fleet-protocol": "workspace:*", "fleet-ship": "workspace:*", diff --git a/apps/cli/src/attach.ts b/apps/cli/src/attach.ts index ae3fc2e..4f84fcf 100644 --- a/apps/cli/src/attach.ts +++ b/apps/cli/src/attach.ts @@ -7,7 +7,8 @@ import { TERMINAL_TAKEOVER_CLOSE_CODE, type ClientMsg, } from "webterm/protocol"; -import { makeClient, unwrap } from "./client"; +import { unwrap } from "fleet-cli-kit"; +import { makeClient } from "./client"; import { renderGrid } from "./render-grid"; /** Ctrl-] — reserved as the detach key; never forwarded to the PTY. */ @@ -49,10 +50,10 @@ export function attachCloseOutcome( async function ensureActive(shipUrl: string, repo: string, name: string): Promise { const client = makeClient(shipUrl); - const status = unwrap(await client.workspaces({ repo })({ name }).get()) as WorkspaceStatus; + const status = unwrap(await client.workspaces({ repo })({ name }).get(), "fleet") as WorkspaceStatus; if (status.state === "inactive") { console.error(`fleet: activating ${repo}/${name}…`); - unwrap(await client.workspaces({ repo })({ name }).activate.post()); + unwrap(await client.workspaces({ repo })({ name }).activate.post(), "fleet"); } } diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index 0bd5ead..4a75aa3 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -1,67 +1,8 @@ import { treaty } from "@elysiajs/eden"; import type { App } from "fleet-ship/api"; -import type { App as BridgeApp } from "fleet-bridge/api"; export type FleetClient = ReturnType>; -export type FleetBridgeClient = ReturnType>; export function makeClient(url: string): FleetClient { return treaty(url); } - -export function makeBridgeClient(url: string): FleetBridgeClient { - return treaty(url); -} - -/** - * Normalize a `--url` value into a full base URL. - * - * Accepts: - * - a bare port, e.g. "4700" -> "http://localhost:4700" - * - a host:port, e.g. "localhost:4700" -> "http://localhost:4700" - * - a full URL, e.g. "http://foo:4700" -> unchanged - */ -export function normalizeUrl(input: string): string { - const trimmed = input.trim().replace(/\/+$/, ""); - - if (/^https?:\/\//i.test(trimmed)) { - return trimmed; - } - - if (/^\d+$/.test(trimmed)) { - return `http://localhost:${trimmed}`; - } - - return `http://${trimmed}`; -} - -export interface EdenResult { - data: T | null; - error: { status: number; value: unknown } | null; -} - -/** - * Unwrap an Eden Treaty response: return `data` on success, or print a clear - * error message to stderr and exit the process with status 1. - */ -export function unwrap(result: EdenResult): T { - if (result.error) { - const status = result.error.status; - const value = result.error.value; - const message = - value && typeof value === "object" && "error" in value && typeof value.error === "string" - ? value.error - : typeof value === "string" - ? value - : JSON.stringify(value); - console.error(`fleet: request failed (${status}): ${message}`); - process.exit(1); - } - - if (result.data === null) { - console.error("fleet: request succeeded but returned no data"); - process.exit(1); - } - - return result.data; -} diff --git a/apps/cli/src/format.ts b/apps/cli/src/format.ts index de26b8d..1d45bdd 100644 --- a/apps/cli/src/format.ts +++ b/apps/cli/src/format.ts @@ -1,18 +1,7 @@ import type { ArmoryEntry, ArmorySyncState, WorkspaceSummary } from "fleet-protocol"; import type { Repo } from "fleet-protocol"; import type { ShipInfo, BridgeWorkspaceSummary, ShipArmoryState } from "fleet-bridge/types"; - -/** With no rows, only the header row is returned. */ -export function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string { - const widths = headers.map((header, col) => - Math.max(header.length, ...rows.map((row) => (row[col] ?? "").length)), - ); - - const formatRow = (cells: readonly string[]): string => - cells.map((cell, col) => cell.padEnd(widths[col] ?? 0)).join(" ").trimEnd(); - - return [formatRow(headers), ...rows.map((row) => formatRow(row))].join("\n"); -} +import { renderTable } from "fleet-cli-kit"; export function formatWorkspaceTable(rows: readonly WorkspaceSummary[]): string { return renderTable( diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 3f7f845..664af0f 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -12,7 +12,8 @@ import { type WorkspaceSummary, } from "fleet-protocol"; import type { ShipInfo, BridgeWorkspaceSummary, ShipArmoryState } from "fleet-bridge/types"; -import { makeBridgeClient, makeClient, normalizeUrl, unwrap } from "./client"; +import { makeBridgeClient, normalizeUrl, unwrap } from "fleet-cli-kit"; +import { makeClient } from "./client"; import { abbreviateRevision, formatArmoryShipTable, @@ -62,7 +63,7 @@ clientCommand options.active ? { active: "true" as const } : options.inactive ? { active: "false" as const } : {}; if (options.wide) { - const rows = unwrap(await bridgeClient().workspaces.get({ query })) as BridgeWorkspaceSummary[]; + const rows = unwrap(await bridgeClient().workspaces.get({ query }), "fleet") as BridgeWorkspaceSummary[]; if (options.json) { console.log(JSON.stringify(rows, null, 2)); } else if (rows.length === 0) { @@ -73,7 +74,7 @@ clientCommand return; } - const rows = unwrap(await client().workspaces.get({ query })) as WorkspaceSummary[]; + const rows = unwrap(await client().workspaces.get({ query }), "fleet") as WorkspaceSummary[]; if (options.json) { console.log(JSON.stringify(rows, null, 2)); } else if (rows.length === 0) { @@ -90,7 +91,7 @@ clientCommand .argument("", "workspace name") .action(async (repo: string, name: string) => { const result = await client().workspaces({ repo })({ name }).get(); - const status = unwrap(result) as WorkspaceStatus; + const status = unwrap(result, "fleet") as WorkspaceStatus; console.log(`repo: ${status.repoName}`); console.log(`name: ${status.name}`); @@ -119,7 +120,7 @@ clientCommand name, branch: options.branch, }); - const summary = unwrap(result) as WorkspaceSummary; + const summary = unwrap(result, "fleet") as WorkspaceSummary; console.log(`created workspace ${summary.repoName}/${summary.name} on branch ${summary.branch}`); }); @@ -132,7 +133,7 @@ clientCommand .argument("", "branch to switch to") .action(async (repo: string, name: string, newBranch: string) => { const result = await client().workspaces({ repo })({ name }).branch.post({ branch: newBranch }); - unwrap(result); + unwrap(result, "fleet"); console.log(`switched ${repo}/${name} to branch ${newBranch}`); }); @@ -144,7 +145,7 @@ clientCommand .argument("", "workspace name") .action(async (repo: string, name: string) => { const result = await client().workspaces({ repo })({ name }).activate.post(); - unwrap(result); + unwrap(result, "fleet"); console.log(`activated ${repo}/${name}`); }); @@ -156,7 +157,7 @@ clientCommand .argument("", "workspace name") .action(async (repo: string, name: string) => { const result = await client().workspaces({ repo })({ name }).deactivate.post(); - unwrap(result); + unwrap(result, "fleet"); console.log(`deactivated ${repo}/${name}`); }); @@ -179,7 +180,7 @@ clientCommand .argument("", "workspace name") .action(async (repo: string, name: string) => { const result = await client().workspaces({ repo })({ name }).delete(); - unwrap(result); + unwrap(result, "fleet"); console.log(`removed ${repo}/${name}`); }); @@ -191,7 +192,7 @@ shipsCommand .description("list the ships registered with the bridge") .option("--json", "output as JSON") .action(async (options: { json?: boolean }) => { - const rows = unwrap(await bridgeClient().ships.get()) as ShipInfo[]; + const rows = unwrap(await bridgeClient().ships.get(), "fleet") as ShipInfo[]; if (options.json) { console.log(JSON.stringify(rows, null, 2)); } else if (rows.length === 0) { @@ -206,7 +207,7 @@ shipsCommand .description("register a ship by its URL (the bridge discovers its name)") .argument("", "base URL of the ship host") .action(async (url: string) => { - const created = unwrap(await bridgeClient().ships.post({ url: normalizeUrl(url) })) as ShipInfo; + const created = unwrap(await bridgeClient().ships.post({ url: normalizeUrl(url) }), "fleet") as ShipInfo; console.log(`registered ship ${created.name} (${created.url})`); }); @@ -215,7 +216,7 @@ shipsCommand .description("deregister a ship") .argument("", "ship name") .action(async (name: string) => { - unwrap(await bridgeClient().ships({ name }).delete()); + unwrap(await bridgeClient().ships({ name }).delete(), "fleet"); console.log(`removed ship ${name}`); }); @@ -228,7 +229,7 @@ reposCommand .description("list the repos registered with the bridge") .option("--json", "output as JSON") .action(async (options: { json?: boolean }) => { - const rows = unwrap(await bridgeClient().repos.get()) as Repo[]; + const rows = unwrap(await bridgeClient().repos.get(), "fleet") as Repo[]; if (options.json) { console.log(JSON.stringify(rows, null, 2)); } else if (rows.length === 0) { @@ -245,7 +246,7 @@ reposCommand .argument("", "git clone URL") .option("-p, --provider ", "where the repo is hosted (e.g. github)") .action(async (name: string, url: string, options: { provider?: string }) => { - const repo = unwrap(await bridgeClient().repos.post({ name, url, provider: options.provider })) as Repo; + const repo = unwrap(await bridgeClient().repos.post({ name, url, provider: options.provider }), "fleet") as Repo; console.log(`registered repo ${repo.name} (${repo.url})`); }); @@ -254,7 +255,7 @@ reposCommand .description("deregister a repo") .argument("", "repo name") .action(async (name: string) => { - unwrap(await bridgeClient().repos({ name }).delete()); + unwrap(await bridgeClient().repos({ name }).delete(), "fleet"); console.log(`removed repo ${name}`); }); @@ -276,7 +277,7 @@ armoryCommand process.exit(1); } - const manifest = unwrap(await bridgeClient().armory.get()) as ArmoryManifest; + const manifest = unwrap(await bridgeClient().armory.get(), "fleet") as ArmoryManifest; const entries = section ? manifest.entries.filter((entry) => entry.section === section) : manifest.entries; @@ -298,7 +299,7 @@ armoryCommand .description("print an armory file's contents") .argument("", "armory-relative path, e.g. skills/my-skill/SKILL.md") .action(async (path: string) => { - const file = unwrap(await bridgeClient().armory.file.get({ query: { path } })) as ArmoryFile; + const file = unwrap(await bridgeClient().armory.file.get({ query: { path } }), "fleet") as ArmoryFile; // Binary bytes re-encoded through stdout would arrive mangled, and a // redirect would capture that silently — refuse rather than hand back a @@ -318,7 +319,7 @@ armoryCommand .description("show what each ship has pulled and installed from the armory") .option("--json", "output as JSON") .action(async (options: { json?: boolean }) => { - const rows = unwrap(await bridgeClient().armory.ships.get()) as ShipArmoryState[]; + const rows = unwrap(await bridgeClient().armory.ships.get(), "fleet") as ShipArmoryState[]; if (options.json) { console.log(JSON.stringify(rows, null, 2)); return; @@ -328,7 +329,7 @@ armoryCommand return; } - const manifest = unwrap(await bridgeClient().armory.get()) as ArmoryManifest; + const manifest = unwrap(await bridgeClient().armory.get(), "fleet") as ArmoryManifest; console.log(formatArmoryShipTable(manifest.revision, rows)); for (const row of rows) { diff --git a/apps/cli/src/launch-command.ts b/apps/cli/src/launch-command.ts index 8a0dee0..9245c01 100644 --- a/apps/cli/src/launch-command.ts +++ b/apps/cli/src/launch-command.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { startBridge } from "fleet-bridge"; import { startShip } from "fleet-ship"; import { startClientServer } from "fleet-client"; -import { normalizeUrl } from "./client"; +import { normalizeUrl } from "fleet-cli-kit"; import { CONFIG_TEMPLATE, loadLaunchConfig, publicUrlWarning } from "./launch-config"; const DEFAULT_CONFIG_PATH = "./fleet-config.yaml"; diff --git a/apps/fagent/package.json b/apps/fagent/package.json index 978b499..2a7a4f3 100644 --- a/apps/fagent/package.json +++ b/apps/fagent/package.json @@ -15,6 +15,7 @@ "commander": "latest", "elysia": "latest", "fleet-bridge": "workspace:*", + "fleet-cli-kit": "workspace:*", "fleet-protocol": "workspace:*" }, "devDependencies": { diff --git a/apps/fagent/src/format.ts b/apps/fagent/src/format.ts index 07868ec..0b68b85 100644 --- a/apps/fagent/src/format.ts +++ b/apps/fagent/src/format.ts @@ -6,21 +6,7 @@ import type { PullRequestSummary, RepoInfo, } from "fleet-bridge/providers"; - -/** - * Copied from the fleet CLI's `format.ts` — the apps do not depend on each - * other. With no rows, only the header row is returned. - */ -export function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string { - const widths = headers.map((header, col) => - Math.max(header.length, ...rows.map((row) => (row[col] ?? "").length)), - ); - - const formatRow = (cells: readonly string[]): string => - cells.map((cell, col) => cell.padEnd(widths[col] ?? 0)).join(" ").trimEnd(); - - return [formatRow(headers), ...rows.map((row) => formatRow(row))].join("\n"); -} +import { renderTable } from "fleet-cli-kit"; /** * The bridge sends timestamps as ISO-8601 strings, but Eden's Treaty client diff --git a/apps/fagent/src/repo-command.ts b/apps/fagent/src/repo-command.ts index 04bd2d7..528421d 100644 --- a/apps/fagent/src/repo-command.ts +++ b/apps/fagent/src/repo-command.ts @@ -11,7 +11,7 @@ import type { Review, } from "fleet-bridge/providers"; import { findWorkspace } from "./agent-workspace"; -import { makeBridgeClient, normalizeUrl, unwrap } from "./client"; +import { makeBridgeClient, normalizeUrl, unwrap } from "fleet-cli-kit"; import { formatCheckList, formatIssue, @@ -92,7 +92,7 @@ repoCommand .description("show metadata about the repo") .action(async () => { const name = await resolveRepo(); - const info = unwrap(await bridge().repos({ name }).info.get()) as RepoInfo; + const info = unwrap(await bridge().repos({ name }).info.get(), "fagent") as RepoInfo; console.log(formatRepoInfo(info)); }); @@ -104,7 +104,7 @@ issue .option("-s, --state ", "filter by state (open|closed|all)") .action(async (options: { state?: StateFilter }) => { const name = await resolveRepo(); - const issues = unwrap(await bridge().repos({ name }).issues.get({ query: { state: options.state } })) as IssueSummary[]; + const issues = unwrap(await bridge().repos({ name }).issues.get({ query: { state: options.state } }), "fagent") as IssueSummary[]; console.log(formatIssueList(issues)); }); @@ -114,7 +114,7 @@ issue .action(async (value: string) => { const name = await resolveRepo(); const number = parseNumber(value); - const result = unwrap(await bridge().repos({ name }).issues({ number }).get()) as Issue; + const result = unwrap(await bridge().repos({ name }).issues({ number }).get(), "fagent") as Issue; console.log(formatIssue(result)); }); @@ -124,7 +124,7 @@ issue .action(async (value: string, body: string) => { const name = await resolveRepo(); const number = parseNumber(value); - const comment = unwrap(await bridge().repos({ name }).issues({ number }).comments.post({ body })) as IssueComment; + const comment = unwrap(await bridge().repos({ name }).issues({ number }).comments.post({ body }), "fagent") as IssueComment; console.log(`commented on issue #${number}: ${comment.url}`); }); @@ -138,7 +138,7 @@ pr .option("-s, --state ", "filter by state (open|closed|all)") .action(async (options: { state?: StateFilter }) => { const name = await resolveRepo(); - const prs = unwrap(await bridge().repos({ name }).pulls.get({ query: { state: options.state } })) as PullRequestSummary[]; + const prs = unwrap(await bridge().repos({ name }).pulls.get({ query: { state: options.state } }), "fagent") as PullRequestSummary[]; console.log(formatPrList(prs)); }); @@ -148,7 +148,7 @@ pr .action(async (value: string) => { const name = await resolveRepo(); const number = parseNumber(value); - const result = unwrap(await bridge().repos({ name }).pulls({ number }).get()) as PullRequest; + const result = unwrap(await bridge().repos({ name }).pulls({ number }).get(), "fagent") as PullRequest; console.log(formatPr(result)); }); @@ -158,7 +158,7 @@ pr .action(async (value: string, body: string) => { const name = await resolveRepo(); const number = parseNumber(value); - const comment = unwrap(await bridge().repos({ name }).pulls({ number }).comments.post({ body })) as IssueComment; + const comment = unwrap(await bridge().repos({ name }).pulls({ number }).comments.post({ body }), "fagent") as IssueComment; console.log(`commented on pr #${number}: ${comment.url}`); }); @@ -186,7 +186,7 @@ repoCommand const name = await resolveRepo(); const number = parseNumber(value); - const review = unwrap(await bridge().repos({ name }).pulls({ number }).reviews.post({ event, body: options.body })) as Review; + const review = unwrap(await bridge().repos({ name }).pulls({ number }).reviews.post({ event, body: options.body }), "fagent") as Review; console.log(`submitted ${event} review on pr #${number}: ${review.url}`); }); @@ -198,7 +198,7 @@ repoCommand .action(async (options: { pr?: string; ref?: string }) => { const query = await resolveCheckTarget(options); const name = await resolveRepo(); - const checks = unwrap(await bridge().repos({ name }).checks.get({ query })) as CheckRun[]; + const checks = unwrap(await bridge().repos({ name }).checks.get({ query }), "fagent") as CheckRun[]; console.log(checks.length === 0 ? "no checks" : formatCheckList(checks)); }); @@ -210,7 +210,7 @@ repoCommand .action(async (options: { pr?: string; ref?: string }) => { const query = await resolveCheckTarget(options); const name = await resolveRepo(); - const logs = unwrap(await bridge().repos({ name }).checks.logs.get({ query })) as FailedJobLog[]; + const logs = unwrap(await bridge().repos({ name }).checks.logs.get({ query }), "fagent") as FailedJobLog[]; if (logs.length === 0) { console.log("no failed jobs"); return; diff --git a/bun.lock b/bun.lock index d27aee7..913665c 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "commander": "latest", "elysia": "latest", "fleet-bridge": "workspace:*", + "fleet-cli-kit": "workspace:*", "fleet-client": "workspace:*", "fleet-protocol": "workspace:*", "fleet-ship": "workspace:*", @@ -65,6 +66,7 @@ "commander": "latest", "elysia": "latest", "fleet-bridge": "workspace:*", + "fleet-cli-kit": "workspace:*", "fleet-protocol": "workspace:*", }, "devDependencies": { @@ -83,6 +85,15 @@ "typescript": "^5", }, }, + "packages/cli-bun": { + "name": "cli-bun", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, "packages/fleet-bridge": { "name": "fleet-bridge", "bin": { @@ -107,6 +118,19 @@ "typescript": "^5", }, }, + "packages/fleet-cli-kit": { + "name": "fleet-cli-kit", + "dependencies": { + "@elysiajs/eden": "latest", + "fleet-bridge": "workspace:*", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, "packages/fleet-client": { "name": "fleet-client", "version": "0.1.0", @@ -181,6 +205,9 @@ }, "packages/git-bun": { "name": "git-bun", + "dependencies": { + "cli-bun": "workspace:*", + }, "devDependencies": { "@types/bun": "latest", }, @@ -190,6 +217,9 @@ }, "packages/tmux-bun": { "name": "tmux-bun", + "dependencies": { + "cli-bun": "workspace:*", + }, "devDependencies": { "@types/bun": "latest", }, @@ -728,6 +758,8 @@ "cli": ["cli@workspace:apps/cli"], + "cli-bun": ["cli-bun@workspace:packages/cli-bun"], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -862,6 +894,8 @@ "fleet-bridge": ["fleet-bridge@workspace:packages/fleet-bridge"], + "fleet-cli-kit": ["fleet-cli-kit@workspace:packages/fleet-cli-kit"], + "fleet-client": ["fleet-client@workspace:packages/fleet-client"], "fleet-design": ["fleet-design@workspace:packages/fleet-design"], diff --git a/packages/cli-bun/.gitignore b/packages/cli-bun/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/packages/cli-bun/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/cli-bun/CLAUDE.md b/packages/cli-bun/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/packages/cli-bun/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/packages/cli-bun/index.test.ts b/packages/cli-bun/index.test.ts new file mode 100644 index 0000000..eacc726 --- /dev/null +++ b/packages/cli-bun/index.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { CliCommand, CliError, toInt, type Backend, type RunResult } from "./index"; + +function fakeBackend(result: RunResult): Backend & { calls: Array } { + const calls: Array = []; + return { + calls, + async run(args: readonly string[]): Promise { + calls.push(args); + return result; + }, + }; +} + +class FakeError extends CliError { + constructor(args: readonly string[], result: RunResult) { + super("fake", args, result); + this.name = "FakeError"; + } +} + +class FakeCommand extends CliCommand { + constructor(backend: Backend) { + super(backend, (args, result) => new FakeError(args, result)); + } + + protected globalArgs(): readonly string[] { + return ["-g", "global"]; + } +} + +describe("toInt", () => { + test("parses integers and falls back to 0", () => { + expect(toInt("42")).toBe(42); + expect(toInt("")).toBe(0); + expect(toInt(undefined)).toBe(0); + expect(toInt("nope")).toBe(0); + }); +}); + +describe("CliError", () => { + test("prefers stderr, falls back to stdout, then to no output", () => { + const base = { stdout: "out\n", stderr: "err\n", exitCode: 2 }; + expect(new CliError("fake", ["a", "b"], base).message).toBe( + "fake a b failed (exit 2): err", + ); + expect(new CliError("fake", ["a"], { ...base, stderr: " " }).message).toBe( + "fake a failed (exit 2): out", + ); + expect( + new CliError("fake", ["a"], { stdout: "", stderr: "", exitCode: 1 }).message, + ).toBe("fake a failed (exit 1): no output"); + }); + + test("carries the args and raw result", () => { + const error = new CliError("fake", ["a"], { stdout: "o", stderr: "e", exitCode: 3 }); + expect(error.args).toEqual(["a"]); + expect(error.stdout).toBe("o"); + expect(error.stderr).toBe("e"); + expect(error.exitCode).toBe(3); + expect(error.name).toBe("CliError"); + }); +}); + +describe("CliCommand", () => { + test("prepends globalArgs to every invocation", async () => { + const backend = fakeBackend({ stdout: "ok\n", stderr: "", exitCode: 0 }); + const command = new FakeCommand(backend); + await command.run(["status"]); + expect(backend.calls[0]).toEqual(["-g", "global", "status"]); + }); + + test("run returns untrimmed stdout and tryRun never throws", async () => { + const ok = new FakeCommand(fakeBackend({ stdout: " ok \n", stderr: "", exitCode: 0 })); + expect(await ok.run(["x"])).toBe(" ok \n"); + + const failing = new FakeCommand(fakeBackend({ stdout: "", stderr: "boom", exitCode: 1 })); + expect((await failing.tryRun(["x"])).exitCode).toBe(1); + await expect(failing.run(["x"])).rejects.toThrow(FakeError); + }); +}); diff --git a/packages/cli-bun/index.ts b/packages/cli-bun/index.ts new file mode 100644 index 0000000..011e21e --- /dev/null +++ b/packages/cli-bun/index.ts @@ -0,0 +1,7 @@ +export { + CliCommand, + type CliErrorFactory, +} from "./src/command"; +export type { Backend, RunResult } from "./src/backend"; +export { CliError } from "./src/errors"; +export { toInt } from "./src/parse"; diff --git a/packages/cli-bun/package.json b/packages/cli-bun/package.json new file mode 100644 index 0000000..1134423 --- /dev/null +++ b/packages/cli-bun/package.json @@ -0,0 +1,19 @@ +{ + "name": "cli-bun", + "module": "index.ts", + "type": "module", + "private": true, + "exports": { + ".": "./index.ts" + }, + "scripts": { + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/packages/cli-bun/src/backend.ts b/packages/cli-bun/src/backend.ts new file mode 100644 index 0000000..aa5b4ba --- /dev/null +++ b/packages/cli-bun/src/backend.ts @@ -0,0 +1,10 @@ +export interface RunResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +// A seam, not decoration: it is what lets a future tmux control-mode backend replace one-shot process spawning. +export interface Backend { + run(args: readonly string[]): Promise; +} diff --git a/packages/cli-bun/src/command.ts b/packages/cli-bun/src/command.ts new file mode 100644 index 0000000..c444d84 --- /dev/null +++ b/packages/cli-bun/src/command.ts @@ -0,0 +1,44 @@ +import type { Backend, RunResult } from "./backend"; +import type { CliError } from "./errors"; + +export type CliErrorFactory = (args: readonly string[], result: RunResult) => CliError; + +/** + * The single choke point through which every command passes. It prepends + * {@link globalArgs} to every invocation, which is what makes whatever those + * flags pin down — working directory, server socket — a hard guarantee: no + * higher-level method touches them at all. + */ +export abstract class CliCommand { + private readonly backend: Backend; + private readonly errorFor: CliErrorFactory; + + protected constructor(backend: Backend, errorFor: CliErrorFactory) { + this.backend = backend; + this.errorFor = errorFor; + } + + /** Flags prepended to every command, before the caller's own args. */ + protected abstract globalArgs(): readonly string[]; + + /** + * Run a command and return its raw result without throwing on a non-zero + * exit. Use this for existence probes and idempotent operations where a + * failure is an expected, meaningful outcome rather than an error. + */ + tryRun(args: readonly string[]): Promise { + return this.backend.run([...this.globalArgs(), ...args]); + } + + /** + * Run a command, throwing the tool's {@link CliError} subclass on a non-zero + * exit. Returns raw stdout (not trimmed) so callers reading file content or + * screen captures keep exact bytes; callers reading a single id/ref should + * `.trim()` the result. + */ + async run(args: readonly string[]): Promise { + const res = await this.tryRun(args); + if (res.exitCode !== 0) throw this.errorFor(args, res); + return res.stdout; + } +} diff --git a/packages/cli-bun/src/errors.ts b/packages/cli-bun/src/errors.ts new file mode 100644 index 0000000..a8d081f --- /dev/null +++ b/packages/cli-bun/src/errors.ts @@ -0,0 +1,25 @@ +import type { RunResult } from "./backend"; + +/** + * Thrown when a command that is expected to succeed exits non-zero. Subclasses + * name the tool they wrap; `binary` prefixes the message so it reads like the + * command line that failed. + */ +export class CliError extends Error { + readonly args: readonly string[]; + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; + + constructor(binary: string, args: readonly string[], result: RunResult) { + // Prefer stderr for the message; fall back to stdout since some tools' errors + // land on stdout depending on the subcommand. + const detail = result.stderr.trim() || result.stdout.trim() || "no output"; + super(`${binary} ${args.join(" ")} failed (exit ${result.exitCode}): ${detail}`); + this.name = "CliError"; + this.args = args; + this.stdout = result.stdout; + this.stderr = result.stderr; + this.exitCode = result.exitCode; + } +} diff --git a/packages/cli-bun/src/parse.ts b/packages/cli-bun/src/parse.ts new file mode 100644 index 0000000..dc7990d --- /dev/null +++ b/packages/cli-bun/src/parse.ts @@ -0,0 +1,4 @@ +export function toInt(value: string | undefined): number { + const n = Number.parseInt(value ?? "", 10); + return Number.isNaN(n) ? 0 : n; +} diff --git a/packages/cli-bun/tsconfig.json b/packages/cli-bun/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/packages/cli-bun/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/packages/fleet-cli-kit/index.ts b/packages/fleet-cli-kit/index.ts new file mode 100644 index 0000000..6bb5334 --- /dev/null +++ b/packages/fleet-cli-kit/index.ts @@ -0,0 +1,8 @@ +export { + makeBridgeClient, + normalizeUrl, + unwrap, + type FleetBridgeClient, + type EdenResult, +} from "./src/client"; +export { renderTable } from "./src/format"; diff --git a/packages/fleet-cli-kit/package.json b/packages/fleet-cli-kit/package.json new file mode 100644 index 0000000..8bd17ee --- /dev/null +++ b/packages/fleet-cli-kit/package.json @@ -0,0 +1,23 @@ +{ + "name": "fleet-cli-kit", + "module": "index.ts", + "type": "module", + "private": true, + "exports": { + ".": "./index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test tests" + }, + "dependencies": { + "@elysiajs/eden": "latest", + "fleet-bridge": "workspace:*" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/apps/fagent/src/client.ts b/packages/fleet-cli-kit/src/client.ts similarity index 66% rename from apps/fagent/src/client.ts rename to packages/fleet-cli-kit/src/client.ts index 917b634..8b3784a 100644 --- a/apps/fagent/src/client.ts +++ b/packages/fleet-cli-kit/src/client.ts @@ -8,12 +8,12 @@ export function makeBridgeClient(url: string): FleetBridgeClient { } /** - * Normalize a `--bridge-url` value into a full base URL. + * Normalize a URL option value into a full base URL. * * Accepts: - * - a bare port, e.g. "4800" -> "http://localhost:4800" - * - a host:port, e.g. "localhost:4800" -> "http://localhost:4800" - * - a full URL, e.g. "http://foo:4800" -> unchanged + * - a bare port, e.g. "4700" -> "http://localhost:4700" + * - a host:port, e.g. "localhost:4700" -> "http://localhost:4700" + * - a full URL, e.g. "http://foo:4700" -> unchanged */ export function normalizeUrl(input: string): string { const trimmed = input.trim().replace(/\/+$/, ""); @@ -36,9 +36,10 @@ export interface EdenResult { /** * Unwrap an Eden Treaty response: return `data` on success, or print a clear - * error message to stderr and exit the process with status 1. + * error message to stderr and exit the process with status 1. `program` prefixes + * the message with the name of the CLI the caller ships as. */ -export function unwrap(result: EdenResult): T { +export function unwrap(result: EdenResult, program: string): T { if (result.error) { const status = result.error.status; const value = result.error.value; @@ -48,12 +49,12 @@ export function unwrap(result: EdenResult): T { : typeof value === "string" ? value : JSON.stringify(value); - console.error(`fagent: request failed (${status}): ${message}`); + console.error(`${program}: request failed (${status}): ${message}`); process.exit(1); } if (result.data === null) { - console.error("fagent: request succeeded but returned no data"); + console.error(`${program}: request succeeded but returned no data`); process.exit(1); } diff --git a/packages/fleet-cli-kit/src/format.ts b/packages/fleet-cli-kit/src/format.ts new file mode 100644 index 0000000..812608d --- /dev/null +++ b/packages/fleet-cli-kit/src/format.ts @@ -0,0 +1,11 @@ +/** With no rows, only the header row is returned. */ +export function renderTable(headers: readonly string[], rows: readonly (readonly string[])[]): string { + const widths = headers.map((header, col) => + Math.max(header.length, ...rows.map((row) => (row[col] ?? "").length)), + ); + + const formatRow = (cells: readonly string[]): string => + cells.map((cell, col) => cell.padEnd(widths[col] ?? 0)).join(" ").trimEnd(); + + return [formatRow(headers), ...rows.map((row) => formatRow(row))].join("\n"); +} diff --git a/apps/cli/tests/client.test.ts b/packages/fleet-cli-kit/tests/client.test.ts similarity index 100% rename from apps/cli/tests/client.test.ts rename to packages/fleet-cli-kit/tests/client.test.ts diff --git a/packages/fleet-cli-kit/tsconfig.json b/packages/fleet-cli-kit/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/packages/fleet-cli-kit/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/packages/git-bun/package.json b/packages/git-bun/package.json index 3f2b420..6445ea4 100644 --- a/packages/git-bun/package.json +++ b/packages/git-bun/package.json @@ -10,6 +10,9 @@ "test": "bun test", "typecheck": "tsc --noEmit" }, + "dependencies": { + "cli-bun": "workspace:*" + }, "devDependencies": { "@types/bun": "latest" }, diff --git a/packages/git-bun/src/backend.ts b/packages/git-bun/src/backend.ts index f5522a3..ca8133d 100644 --- a/packages/git-bun/src/backend.ts +++ b/packages/git-bun/src/backend.ts @@ -1,16 +1,12 @@ -export interface GitRunResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} +import type { Backend, RunResult } from "cli-bun"; + +export type GitRunResult = RunResult; /** * The `args` a backend receives already include the `-C ` working-directory * flags, so a backend must never inject its own. */ -export interface GitBackend { - run(args: readonly string[]): Promise; -} +export type GitBackend = Backend; /** * Default backend: spawn one-shot `git` processes via Bun's shell. Bun.$ diff --git a/packages/git-bun/src/command.ts b/packages/git-bun/src/command.ts index b98ed8a..1a063a6 100644 --- a/packages/git-bun/src/command.ts +++ b/packages/git-bun/src/command.ts @@ -1,4 +1,5 @@ -import { ShellBackend, type GitBackend, type GitRunResult } from "./backend"; +import { CliCommand } from "cli-bun"; +import { ShellBackend, type GitBackend } from "./backend"; import { GitError } from "./errors"; export interface GitCommandOptions { @@ -24,40 +25,22 @@ export interface GitCommandOptions { * `-C ` to every invocation, which is what makes the working directory a * hard guarantee: no higher-level method touches the `-C` flag at all. */ -export class GitCommand { +export class GitCommand extends CliCommand { readonly cwd: string; - private readonly backend: GitBackend; constructor(options: GitCommandOptions, backend?: GitBackend) { + super( + backend ?? new ShellBackend(options.binary, options.env), + (args, result) => new GitError(args, result), + ); this.cwd = options.cwd; - this.backend = backend ?? new ShellBackend(options.binary, options.env); } /** * `-C ` runs git as if it had been started in `cwd`, without changing the * parent process's own working directory. */ - private globalArgs(): readonly string[] { + protected globalArgs(): readonly string[] { return ["-C", this.cwd]; } - - /** - * Run a command and return its raw result without throwing on a non-zero - * exit. Use this for existence probes and idempotent operations where a - * failure is an expected, meaningful outcome rather than an error. - */ - tryRun(args: readonly string[]): Promise { - return this.backend.run([...this.globalArgs(), ...args]); - } - - /** - * Run a command, throwing {@link GitError} on a non-zero exit. Returns raw - * stdout (not trimmed) so callers reading diffs or file content keep exact - * bytes; callers reading a single id/ref should `.trim()` the result. - */ - async run(args: readonly string[]): Promise { - const res = await this.tryRun(args); - if (res.exitCode !== 0) throw new GitError(args, res); - return res.stdout; - } } diff --git a/packages/git-bun/src/errors.ts b/packages/git-bun/src/errors.ts index 284a8e9..2235b1b 100644 --- a/packages/git-bun/src/errors.ts +++ b/packages/git-bun/src/errors.ts @@ -1,3 +1,4 @@ +import { CliError } from "cli-bun"; import type { GitRunResult } from "./backend"; /** @@ -6,21 +7,9 @@ import type { GitRunResult } from "./backend"; * the expected non-zero exit into `false`/`undefined` — so a GitError always * signals a genuine failure worth surfacing. */ -export class GitError extends Error { - readonly args: readonly string[]; - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; - +export class GitError extends CliError { constructor(args: readonly string[], result: GitRunResult) { - // Prefer stderr for the message; fall back to stdout since some git errors - // land on stdout depending on the subcommand. - const detail = result.stderr.trim() || result.stdout.trim() || "no output"; - super(`git ${args.join(" ")} failed (exit ${result.exitCode}): ${detail}`); + super("git", args, result); this.name = "GitError"; - this.args = args; - this.stdout = result.stdout; - this.stderr = result.stderr; - this.exitCode = result.exitCode; } } diff --git a/packages/git-bun/src/format.ts b/packages/git-bun/src/format.ts index cfe4fb0..ac72f2d 100644 --- a/packages/git-bun/src/format.ts +++ b/packages/git-bun/src/format.ts @@ -1,3 +1,4 @@ +import { toInt } from "cli-bun"; import type { BranchInfo, CommitInfo, FileStatus, RemoteRef, StatusInfo, WorktreeInfo } from "./types"; // Field separator woven into every machine-readable `--format`/`--pretty` string. @@ -6,11 +7,6 @@ import type { BranchInfo, CommitInfo, FileStatus, RemoteRef, StatusInfo, Worktre // fooled by content the way a space or tab could. export const FIELD_SEP = "\u001f"; -function toInt(value: string | undefined): number { - const n = Number.parseInt(value ?? "", 10); - return Number.isNaN(n) ? 0 : n; -} - // %H sha, %h short sha, %an author name, %ae author email, %at author date // (unix seconds), %s subject. Every field is single-line, so one commit is one // output line and records split cleanly on "\n". diff --git a/packages/tmux-bun/package.json b/packages/tmux-bun/package.json index f76806a..5e82818 100644 --- a/packages/tmux-bun/package.json +++ b/packages/tmux-bun/package.json @@ -10,6 +10,9 @@ "test": "bun test", "typecheck": "tsc --noEmit" }, + "dependencies": { + "cli-bun": "workspace:*" + }, "devDependencies": { "@types/bun": "latest" }, diff --git a/packages/tmux-bun/src/backend.ts b/packages/tmux-bun/src/backend.ts index d6edef6..fad8d4c 100644 --- a/packages/tmux-bun/src/backend.ts +++ b/packages/tmux-bun/src/backend.ts @@ -1,16 +1,12 @@ -export interface TmuxRunResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} +import type { Backend, RunResult } from "cli-bun"; + +export type TmuxRunResult = RunResult; /** * The `args` a backend receives already include the namespace socket flags, so a * backend must never inject its own. */ -export interface TmuxBackend { - run(args: readonly string[]): Promise; -} +export type TmuxBackend = Backend; /** * Default backend: spawn one-shot `tmux` processes via Bun's shell. Bun.$ diff --git a/packages/tmux-bun/src/command.ts b/packages/tmux-bun/src/command.ts index bbc3f6c..680be62 100644 --- a/packages/tmux-bun/src/command.ts +++ b/packages/tmux-bun/src/command.ts @@ -1,4 +1,5 @@ -import { ShellBackend, type TmuxBackend, type TmuxRunResult } from "./backend"; +import { CliCommand } from "cli-bun"; +import { ShellBackend, type TmuxBackend } from "./backend"; import { TmuxError } from "./errors"; export interface TmuxCommandOptions { @@ -25,17 +26,19 @@ export interface TmuxCommandOptions { * the namespace socket flags to every invocation, which is what makes namespace * isolation a hard guarantee: no higher-level method touches the socket flags. */ -export class TmuxCommand { +export class TmuxCommand extends CliCommand { readonly namespace: string; private readonly socketPath?: string; private readonly configFile?: string; - private readonly backend: TmuxBackend; constructor(options: TmuxCommandOptions, backend?: TmuxBackend) { + super( + backend ?? new ShellBackend(options.binary), + (args, result) => new TmuxError(args, result), + ); this.namespace = options.namespace; this.socketPath = options.socketPath; this.configFile = options.configFile; - this.backend = backend ?? new ShellBackend(options.binary); } /** @@ -44,29 +47,9 @@ export class TmuxCommand { * (explicit path) takes precedence over `-L` (named namespace) to match tmux's * own semantics. */ - private globalArgs(): readonly string[] { + protected globalArgs(): readonly string[] { const config = this.configFile ? ["-f", this.configFile] : []; const socket = this.socketPath ? ["-S", this.socketPath] : ["-L", this.namespace]; return [...config, ...socket]; } - - /** - * Run a command and return its raw result without throwing on a non-zero - * exit. Use this for existence probes and idempotent teardown where a - * failure is an expected, meaningful outcome rather than an error. - */ - tryRun(args: readonly string[]): Promise { - return this.backend.run([...this.globalArgs(), ...args]); - } - - /** - * Run a command, throwing {@link TmuxError} on a non-zero exit. Returns raw - * stdout (not trimmed) so callers such as `capturePane` keep exact content; - * callers reading a single id should `.trim()` the result. - */ - async run(args: readonly string[]): Promise { - const res = await this.tryRun(args); - if (res.exitCode !== 0) throw new TmuxError(args, res); - return res.stdout; - } } diff --git a/packages/tmux-bun/src/errors.ts b/packages/tmux-bun/src/errors.ts index 8345862..92ac236 100644 --- a/packages/tmux-bun/src/errors.ts +++ b/packages/tmux-bun/src/errors.ts @@ -1,3 +1,4 @@ +import { CliError } from "cli-bun"; import type { TmuxRunResult } from "./backend"; /** @@ -6,21 +7,9 @@ import type { TmuxRunResult } from "./backend"; * the non-zero exit into `false` — so a TmuxError always signals a genuine * failure worth surfacing. */ -export class TmuxError extends Error { - readonly args: readonly string[]; - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; - +export class TmuxError extends CliError { constructor(args: readonly string[], result: TmuxRunResult) { - // Prefer stderr for the message; fall back to stdout since some tmux errors - // land on stdout depending on the subcommand. - const detail = result.stderr.trim() || result.stdout.trim() || "no output"; - super(`tmux ${args.join(" ")} failed (exit ${result.exitCode}): ${detail}`); + super("tmux", args, result); this.name = "TmuxError"; - this.args = args; - this.stdout = result.stdout; - this.stderr = result.stderr; - this.exitCode = result.exitCode; } } diff --git a/packages/tmux-bun/src/format.ts b/packages/tmux-bun/src/format.ts index 6ea30de..59415ff 100644 --- a/packages/tmux-bun/src/format.ts +++ b/packages/tmux-bun/src/format.ts @@ -1,3 +1,4 @@ +import { toInt } from "cli-bun"; import type { SessionInfo, WindowInfo, PaneInfo } from "./types"; // Field separator woven into every `-F` format string. ASCII Unit Separator @@ -29,11 +30,6 @@ function parseRows(stdout: string, fields: readonly string[]): Array Date: Sun, 2 Aug 2026 17:08:42 -0500 Subject: [PATCH 5/6] Map route errors in one place per plugin The mapError catch tail was inlined 40 times across the two API layers. Register it once per Elysia plugin instead and let handlers throw. The hook guards on instanceof against Elysia's own error types: it derives its error code from error.code, so an unguarded hook renders validation failures as 500 and a code-string guard lets an ENOENT-tagged error escape unmapped. Two sites keep their inline catch deliberately. Ship's GET /armory needs mapArmoryError, which maps ArmorySyncError to its own status rather than 500. The terminal proxy's upstream.onmessage binary site closes ws before upstream, the reverse of its three siblings, and the ordering could not be shown immaterial. Also drop createApp's never-read config parameter, de-export TERMINAL_INIT_TIMEOUT_MS, and name the terminal proxy's repeated socket state and teardown. Differential probes over every route by error class confirm status, content-type, and body are byte-identical: 647/647 on the bridge, and on the ship every reachable case, the 10 exceptions all requiring an async rejection from agentStatus, which returns synchronously. Typecheck clean; 751 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/api/armory.ts | 31 +--- packages/fleet-bridge/src/api/http.ts | 28 +++ packages/fleet-bridge/src/api/index.ts | 3 +- packages/fleet-bridge/src/api/repos.ts | 141 +++------------ packages/fleet-bridge/src/api/ships.ts | 25 +-- .../fleet-bridge/src/api/system-resources.ts | 13 +- packages/fleet-bridge/src/api/workspaces.ts | 157 ++++++---------- packages/fleet-bridge/src/index.ts | 2 +- packages/fleet-bridge/tests/api.test.ts | 2 +- .../fleet-bridge/tests/armory-ships.test.ts | 2 +- packages/fleet-bridge/tests/armory.test.ts | 2 +- .../tests/repo-provider-api.test.ts | 2 +- .../fleet-bridge/tests/terminal-proxy.test.ts | 2 +- packages/fleet-ship/src/api/armory.ts | 19 +- packages/fleet-ship/src/api/http.ts | 28 +++ packages/fleet-ship/src/api/workspaces.ts | 168 +++++------------- 16 files changed, 204 insertions(+), 421 deletions(-) diff --git a/packages/fleet-bridge/src/api/armory.ts b/packages/fleet-bridge/src/api/armory.ts index 1a9bdc6..9bf2aee 100644 --- a/packages/fleet-bridge/src/api/armory.ts +++ b/packages/fleet-bridge/src/api/armory.ts @@ -1,30 +1,13 @@ import { Elysia, t } from "elysia"; import type { FleetManager } from "../fleet-manager"; -import { mapError } from "./http"; +import { mapErrorHook } from "./http"; export function armoryPlugin(manager: FleetManager) { return new Elysia({ name: "bridge-armory" }) - .get("/armory", async ({ set }) => { - try { - return await manager.armoryManifest(); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }) - .get( - "/armory/file", - async ({ query, set }) => { - try { - return await manager.armoryFile(query.path); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, - { query: t.Object({ path: t.String() }) }, - ) - .get("/armory/ships", () => manager.armoryShipStates()); + .get("/armory/ships", () => manager.armoryShipStates()) + .onError(mapErrorHook) + .get("/armory", () => manager.armoryManifest()) + .get("/armory/file", ({ query }) => manager.armoryFile(query.path), { + query: t.Object({ path: t.String() }), + }); } diff --git a/packages/fleet-bridge/src/api/http.ts b/packages/fleet-bridge/src/api/http.ts index 5d71da0..e63d107 100644 --- a/packages/fleet-bridge/src/api/http.ts +++ b/packages/fleet-bridge/src/api/http.ts @@ -1,3 +1,4 @@ +import { InvalidCookieSignature, InvalidFileType, NotFoundError, ParseError, ValidationError } from "elysia"; import { BridgeError } from "../fleet-manager"; import { ProviderError } from "../providers"; @@ -7,3 +8,30 @@ export function mapError(err: unknown): { status: number; body: { error: string } return { status: 500, body: { error: err instanceof Error ? err.message : String(err) } }; } + +type ErrorContext = { error: unknown; set: { status?: number | string } }; + +/** + * Elysia raises its own errors (validation, parse, unmatched route) around the + * handler rather than inside it, so they were unreachable from the per-route + * `try`/`catch` this hook replaces. Returning `undefined` leaves them to + * Elysia's own rendering — mapping them would turn a 422 into a 500. + */ +export function errorHook(map: (err: unknown) => { status: number; body: { error: string } }) { + return ({ error, set }: ErrorContext) => { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof ParseError || + error instanceof InvalidCookieSignature || + error instanceof InvalidFileType + ) { + return; + } + const mapped = map(error); + set.status = mapped.status; + return mapped.body; + }; +} + +export const mapErrorHook = errorHook(mapError); diff --git a/packages/fleet-bridge/src/api/index.ts b/packages/fleet-bridge/src/api/index.ts index 77a62d3..f10cd49 100644 --- a/packages/fleet-bridge/src/api/index.ts +++ b/packages/fleet-bridge/src/api/index.ts @@ -1,7 +1,6 @@ import { Elysia } from "elysia"; import { MAX_CLIENT_FRAME_BYTES } from "webterm/protocol"; import type { FleetManager } from "../fleet-manager"; -import type { BridgeConfig } from "../config"; import { workspacesPlugin } from "./workspaces"; import { shipsPlugin } from "./ships"; import { systemResourcesPlugin } from "./system-resources"; @@ -10,7 +9,7 @@ import { armoryPlugin } from "./armory"; import { eventsPlugin } from "./events"; import { Logestic } from "logestic"; -export function createApp(manager: FleetManager, _config: BridgeConfig) { +export function createApp(manager: FleetManager) { // Terminal frames are highly repetitive JSON; permessage-deflate is worth an // order of magnitude on them, and Bun's client WebSocket already offers the // extension, so accepting it here compresses the fleet-client→bridge hop. diff --git a/packages/fleet-bridge/src/api/repos.ts b/packages/fleet-bridge/src/api/repos.ts index c1e9345..8d90d8b 100644 --- a/packages/fleet-bridge/src/api/repos.ts +++ b/packages/fleet-bridge/src/api/repos.ts @@ -1,29 +1,16 @@ import { Elysia, t } from "elysia"; import type { FleetManager } from "../fleet-manager"; -import { mapError } from "./http"; +import { mapErrorHook } from "./http"; export function reposPlugin(manager: FleetManager) { return new Elysia({ name: "bridge-repos" }) - .get("/repos", async ({ set }) => { - try { - return await manager.listRepos(); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }) + .onError(mapErrorHook) + .get("/repos", () => manager.listRepos()) .post( "/repos", async ({ body, set }) => { - try { - set.status = 201; - return await manager.addRepo(body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + set.status = 201; + return await manager.addRepo(body); }, { body: t.Object({ @@ -33,116 +20,50 @@ export function reposPlugin(manager: FleetManager) { }), }, ) - .delete("/repos/:name", async ({ params, set }) => { - try { - await manager.removeRepo(params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }) - .get("/repos/:name/info", async ({ params, set }) => { - try { - return await manager.repoInfo(params.name); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .delete("/repos/:name", async ({ params }) => { + await manager.removeRepo(params.name); + return { ok: true as const }; }) + .get("/repos/:name/info", ({ params }) => manager.repoInfo(params.name)) .get( "/repos/:name/issues", - async ({ params, query, set }) => { - try { - return await manager.listRepoIssues(params.name, { state: query.state }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => manager.listRepoIssues(params.name, { state: query.state }), { query: stateQuery }, ) - .get( - "/repos/:name/issues/:number", - async ({ params, set }) => { - try { - return await manager.getRepoIssue(params.name, params.number); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, - { params: numberParams }, - ) + .get("/repos/:name/issues/:number", ({ params }) => manager.getRepoIssue(params.name, params.number), { + params: numberParams, + }) .post( "/repos/:name/issues/:number/comments", async ({ params, body, set }) => { - try { - set.status = 201; - return await manager.commentRepoIssue(params.name, params.number, body.body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + set.status = 201; + return await manager.commentRepoIssue(params.name, params.number, body.body); }, { params: numberParams, body: t.Object({ body: t.String() }) }, ) .get( "/repos/:name/pulls", - async ({ params, query, set }) => { - try { - return await manager.listRepoPullRequests(params.name, { state: query.state }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => manager.listRepoPullRequests(params.name, { state: query.state }), { query: stateQuery }, ) .get( "/repos/:name/pulls/:number", - async ({ params, set }) => { - try { - return await manager.getRepoPullRequest(params.name, params.number); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params }) => manager.getRepoPullRequest(params.name, params.number), { params: numberParams }, ) .post( "/repos/:name/pulls/:number/comments", async ({ params, body, set }) => { - try { - set.status = 201; - return await manager.commentRepoPullRequest(params.name, params.number, body.body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + set.status = 201; + return await manager.commentRepoPullRequest(params.name, params.number, body.body); }, { params: numberParams, body: t.Object({ body: t.String() }) }, ) .post( "/repos/:name/pulls/:number/reviews", async ({ params, body, set }) => { - try { - set.status = 201; - return await manager.reviewRepoPullRequest(params.name, params.number, body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + set.status = 201; + return await manager.reviewRepoPullRequest(params.name, params.number, body); }, { params: numberParams, @@ -154,28 +75,12 @@ export function reposPlugin(manager: FleetManager) { ) .get( "/repos/:name/checks", - async ({ params, query, set }) => { - try { - return await manager.listRepoChecks(params.name, { ref: query.ref, pr: query.pr }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => manager.listRepoChecks(params.name, { ref: query.ref, pr: query.pr }), { query: checkTargetQuery }, ) .get( "/repos/:name/checks/logs", - async ({ params, query, set }) => { - try { - return await manager.getRepoFailedLogs(params.name, { ref: query.ref, pr: query.pr }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => manager.getRepoFailedLogs(params.name, { ref: query.ref, pr: query.pr }), { query: checkTargetQuery }, ); } diff --git a/packages/fleet-bridge/src/api/ships.ts b/packages/fleet-bridge/src/api/ships.ts index e0d41a0..a92f892 100644 --- a/packages/fleet-bridge/src/api/ships.ts +++ b/packages/fleet-bridge/src/api/ships.ts @@ -1,32 +1,21 @@ import { Elysia, t } from "elysia"; import type { FleetManager } from "../fleet-manager"; -import { mapError } from "./http"; +import { mapErrorHook } from "./http"; export function shipsPlugin(manager: FleetManager) { return new Elysia({ name: "bridge-ships" }) .get("/ships", () => manager.listShips()) + .onError(mapErrorHook) .post( "/ships", async ({ body, set }) => { - try { - set.status = 201; - return await manager.addShip(body.url); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + set.status = 201; + return await manager.addShip(body.url); }, { body: t.Object({ url: t.String() }) }, ) - .delete("/ships/:name", async ({ params, set }) => { - try { - await manager.removeShip(params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .delete("/ships/:name", async ({ params }) => { + await manager.removeShip(params.name); + return { ok: true as const }; }); } diff --git a/packages/fleet-bridge/src/api/system-resources.ts b/packages/fleet-bridge/src/api/system-resources.ts index e470185..6bcef5c 100644 --- a/packages/fleet-bridge/src/api/system-resources.ts +++ b/packages/fleet-bridge/src/api/system-resources.ts @@ -1,17 +1,10 @@ import { Elysia } from "elysia"; import type { FleetManager } from "../fleet-manager"; -import { mapError } from "./http"; +import { mapErrorHook } from "./http"; export function systemResourcesPlugin(manager: FleetManager) { return new Elysia({ name: "bridge-system-resources" }) .get("/system-resources", () => manager.listSystemResources()) - .get("/ships/:ship/system-resources", async ({ params, set }) => { - try { - return await manager.getShipSystemResources(params.ship); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }); + .onError(mapErrorHook) + .get("/ships/:ship/system-resources", ({ params }) => manager.getShipSystemResources(params.ship)); } diff --git a/packages/fleet-bridge/src/api/workspaces.ts b/packages/fleet-bridge/src/api/workspaces.ts index 5dae663..3cc7a36 100644 --- a/packages/fleet-bridge/src/api/workspaces.ts +++ b/packages/fleet-bridge/src/api/workspaces.ts @@ -12,53 +12,47 @@ import { } from "webterm/protocol"; import type { ServerMsg } from "webterm/protocol"; import type { FleetManager } from "../fleet-manager"; -import { mapError } from "./http"; +import { mapErrorHook } from "./http"; + +type TerminalProxyData = { upstream?: WebSocket; buffer?: string[]; pendingBytes?: number }; + +function abort( + data: TerminalProxyData, + ws: { close(code?: number, reason?: string): unknown }, + code: number, + reason: string, +) { + data.buffer?.splice(0); + data.pendingBytes = 0; + data.upstream?.close(code, reason); + ws.close(code, reason); +} export function workspacesPlugin(manager: FleetManager) { return new Elysia({ name: "bridge-workspaces" }) + .onError(mapErrorHook) .get( "/workspaces", - async ({ query, set }) => { - try { - const filter = - query.active === "true" ? "active" : query.active === "false" ? "inactive" : undefined; - return await manager.listWorkspaces(filter); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + ({ query }) => { + const filter = + query.active === "true" ? "active" : query.active === "false" ? "inactive" : undefined; + return manager.listWorkspaces(filter); }, { query: t.Object({ active: t.Optional(t.String()) }) }, ) - .get("/workspaces/:repo/:name", async ({ params, set }) => { - try { - return await manager.getWorkspace(params.repo, params.name); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }) + .get("/workspaces/:repo/:name", ({ params }) => manager.getWorkspace(params.repo, params.name)) .get( "/workspaces/:repo/:name/diff", - async ({ params, query, set }) => { - try { - return await manager.getWorkspaceDiff(params.repo, params.name, { - staged: query.staged, - stat: query.stat, - nameOnly: query.nameOnly, - range: query.range, - mergeBase: query.mergeBase, - paths: query.paths, - includeUntracked: query.includeUntracked, - }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => + manager.getWorkspaceDiff(params.repo, params.name, { + staged: query.staged, + stat: query.stat, + nameOnly: query.nameOnly, + range: query.range, + mergeBase: query.mergeBase, + paths: query.paths, + includeUntracked: query.includeUntracked, + }), { query: t.Object({ staged: t.Optional(t.Boolean()), @@ -73,15 +67,7 @@ export function workspacesPlugin(manager: FleetManager) { ) .get( "/workspaces/:repo/:name/refs", - async ({ params, query, set }) => { - try { - return await manager.getWorkspaceRefs(params.repo, params.name, { commits: query.commits }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => manager.getWorkspaceRefs(params.repo, params.name, { commits: query.commits }), { query: t.Object({ commits: t.Optional(t.Number()), @@ -91,14 +77,8 @@ export function workspacesPlugin(manager: FleetManager) { .post( "/workspaces", async ({ body, set }) => { - try { - set.status = 201; - return await manager.createWorkspace(body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + set.status = 201; + return await manager.createWorkspace(body); }, { body: t.Object({ @@ -111,47 +91,23 @@ export function workspacesPlugin(manager: FleetManager) { ) .post( "/workspaces/:repo/:name/branch", - async ({ params, body, set }) => { - try { - await manager.switchBranch(params.repo, params.name, body.branch); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + async ({ params, body }) => { + await manager.switchBranch(params.repo, params.name, body.branch); + return { ok: true as const }; }, { body: t.Object({ branch: t.String() }) }, ) - .post("/workspaces/:repo/:name/activate", async ({ params, set }) => { - try { - await manager.activate(params.repo, params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .post("/workspaces/:repo/:name/activate", async ({ params }) => { + await manager.activate(params.repo, params.name); + return { ok: true as const }; }) - .post("/workspaces/:repo/:name/deactivate", async ({ params, set }) => { - try { - await manager.deactivate(params.repo, params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .post("/workspaces/:repo/:name/deactivate", async ({ params }) => { + await manager.deactivate(params.repo, params.name); + return { ok: true as const }; }) - .delete("/workspaces/:repo/:name", async ({ params, set }) => { - try { - await manager.remove(params.repo, params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .delete("/workspaces/:repo/:name", async ({ params }) => { + await manager.remove(params.repo, params.name); + return { ok: true as const }; }) .ws("/workspaces/:repo/:name/terminal", { query: t.Object({ @@ -174,7 +130,7 @@ export function workspacesPlugin(manager: FleetManager) { // the upstream socket is open so the browser's first `init` isn't lost. const upstream = new WebSocket(target); const buffer: string[] = []; - const data = ws.data as { upstream?: WebSocket; buffer?: string[]; pendingBytes?: number }; + const data = ws.data as TerminalProxyData; data.upstream = upstream; data.buffer = buffer; data.pendingBytes = 0; @@ -211,34 +167,25 @@ export function workspacesPlugin(manager: FleetManager) { }; }, message(ws, message) { - const data = ws.data as { upstream?: WebSocket; buffer?: string[]; pendingBytes?: number }; + const data = ws.data as TerminalProxyData; const upstream = data.upstream; if (!upstream) return; if (ArrayBuffer.isView(message) || message instanceof ArrayBuffer) { - data.buffer?.splice(0); - data.pendingBytes = 0; - upstream.close(BINARY_MESSAGE_CLOSE_CODE, BINARY_MESSAGE_CLOSE_REASON); - ws.close(BINARY_MESSAGE_CLOSE_CODE, BINARY_MESSAGE_CLOSE_REASON); + abort(data, ws, BINARY_MESSAGE_CLOSE_CODE, BINARY_MESSAGE_CLOSE_REASON); return; } let frame: string; try { frame = JSON.stringify(decodeClientMessage(message)); } catch { - data.buffer?.splice(0); - data.pendingBytes = 0; - upstream.close(INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON); - ws.close(INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON); + abort(data, ws, INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON); return; } if (upstream.readyState === WebSocket.OPEN) upstream.send(frame); else { const pendingBytes = (data.pendingBytes ?? 0) + utf8ByteLength(frame); if (pendingBytes > MAX_PENDING_BYTES) { - data.buffer?.splice(0); - data.pendingBytes = 0; - upstream.close(BUFFER_LIMIT_CLOSE_CODE, BUFFER_LIMIT_CLOSE_REASON); - ws.close(BUFFER_LIMIT_CLOSE_CODE, BUFFER_LIMIT_CLOSE_REASON); + abort(data, ws, BUFFER_LIMIT_CLOSE_CODE, BUFFER_LIMIT_CLOSE_REASON); return; } data.buffer?.push(frame); @@ -246,7 +193,7 @@ export function workspacesPlugin(manager: FleetManager) { } }, close(ws, code, reason) { - const data = ws.data as { upstream?: WebSocket; buffer?: string[]; pendingBytes?: number }; + const data = ws.data as TerminalProxyData; data.buffer?.splice(0); data.pendingBytes = 0; try { diff --git a/packages/fleet-bridge/src/index.ts b/packages/fleet-bridge/src/index.ts index 48e9f6d..43c81b5 100755 --- a/packages/fleet-bridge/src/index.ts +++ b/packages/fleet-bridge/src/index.ts @@ -36,7 +36,7 @@ export async function startBridge( void manager.pushArmory(); }); - const app = createApp(manager, config); + const app = createApp(manager); app.listen(config.port); console.log(`fleet-bridge "${config.name}" listening on http://localhost:${config.port}`); return { manager, watcher }; diff --git a/packages/fleet-bridge/tests/api.test.ts b/packages/fleet-bridge/tests/api.test.ts index 529691c..2d36b36 100644 --- a/packages/fleet-bridge/tests/api.test.ts +++ b/packages/fleet-bridge/tests/api.test.ts @@ -54,7 +54,7 @@ describe("bridge API", () => { await store.createShip({ name: "ship-b", url: "http://ship-b" }); manager = new FleetManager(config, makeDeps(ships), { syncTimeoutMs: 50, store }); await manager.init(); - app = createApp(manager, config); + app = createApp(manager); }); afterEach(async () => { manager.shutdown(); diff --git a/packages/fleet-bridge/tests/armory-ships.test.ts b/packages/fleet-bridge/tests/armory-ships.test.ts index cffc949..c6ad75a 100644 --- a/packages/fleet-bridge/tests/armory-ships.test.ts +++ b/packages/fleet-bridge/tests/armory-ships.test.ts @@ -108,7 +108,7 @@ describe("FleetManager armoryShipStates", () => { ["http://ship-a", { name: "ship-a", workspaces: [], armoryState: SYNCED }], ]); const mgr = await boot(ships); - const app = createApp(mgr, { dataDirectory: dir, port: 4800, name: "bridge" }); + const app = createApp(mgr); const response = await app.handle(new Request("http://bridge/armory/ships")); diff --git a/packages/fleet-bridge/tests/armory.test.ts b/packages/fleet-bridge/tests/armory.test.ts index 726f54e..dee6d08 100644 --- a/packages/fleet-bridge/tests/armory.test.ts +++ b/packages/fleet-bridge/tests/armory.test.ts @@ -365,7 +365,7 @@ describe("armory API", () => { store, }); await manager.init(); - return { root: join(directory, "armory"), app: createApp(manager, config) }; + return { root: join(directory, "armory"), app: createApp(manager) }; } async function call(handler: ReturnType, path: string) { diff --git a/packages/fleet-bridge/tests/repo-provider-api.test.ts b/packages/fleet-bridge/tests/repo-provider-api.test.ts index fb9fcbd..14d7363 100644 --- a/packages/fleet-bridge/tests/repo-provider-api.test.ts +++ b/packages/fleet-bridge/tests/repo-provider-api.test.ts @@ -194,7 +194,7 @@ describe("repo provider API", () => { providerFor: makeProvider, }); await manager.init(); - app = createApp(manager, config); + app = createApp(manager); // Register the repo so the lookup in withProvider succeeds. expect((await call("POST", "/repos", { name: "repo1", url: "https://github.com/acme/repo1", provider: "github" })).status).toBe(201); }); diff --git a/packages/fleet-bridge/tests/terminal-proxy.test.ts b/packages/fleet-bridge/tests/terminal-proxy.test.ts index 558b210..8969aa7 100644 --- a/packages/fleet-bridge/tests/terminal-proxy.test.ts +++ b/packages/fleet-bridge/tests/terminal-proxy.test.ts @@ -79,7 +79,7 @@ describe("bridge terminal proxy", () => { manager = new FleetManager(config, makeDeps(ships), { syncTimeoutMs: 50, store }); await manager.init(); - bridge = createApp(manager, config); + bridge = createApp(manager); bridge.listen(0); bridgeUrl = `ws://localhost:${bridge.server?.port}`; }); diff --git a/packages/fleet-ship/src/api/armory.ts b/packages/fleet-ship/src/api/armory.ts index 44a3a7e..cf0ef18 100644 --- a/packages/fleet-ship/src/api/armory.ts +++ b/packages/fleet-ship/src/api/armory.ts @@ -1,23 +1,10 @@ import { Elysia, t } from "elysia"; import { ArmoryCache, ArmorySyncError } from "../armory/armory-cache"; import { syncAndInstall } from "../armory/armory-sync"; -import { mapError } from "./http"; +import { errorHook, mapError } from "./http"; export function armoryPlugin(cache: ArmoryCache) { return new Elysia({ name: "ship-armory" }) - .post( - "/armory/sync", - async ({ body, set }) => { - try { - return await syncAndInstall(cache, body); - } catch (err) { - const mapped = mapArmoryError(err); - set.status = mapped.status; - return mapped.body; - } - }, - { body: t.Object({ bridgeUrl: t.String(), revision: t.String() }) }, - ) .get("/armory", async ({ set }) => { try { return await cache.state(); @@ -26,6 +13,10 @@ export function armoryPlugin(cache: ArmoryCache) { set.status = mapped.status; return mapped.body; } + }) + .onError(errorHook(mapArmoryError)) + .post("/armory/sync", ({ body }) => syncAndInstall(cache, body), { + body: t.Object({ bridgeUrl: t.String(), revision: t.String() }), }); } diff --git a/packages/fleet-ship/src/api/http.ts b/packages/fleet-ship/src/api/http.ts index ee6a742..0e7e8c6 100644 --- a/packages/fleet-ship/src/api/http.ts +++ b/packages/fleet-ship/src/api/http.ts @@ -1,3 +1,4 @@ +import { InvalidCookieSignature, InvalidFileType, NotFoundError, ParseError, ValidationError } from "elysia"; import { WorkspaceError } from "../workspace-manager"; export function mapError(err: unknown): { status: number; body: { error: string } } { @@ -6,3 +7,30 @@ export function mapError(err: unknown): { status: number; body: { error: string } return { status: 500, body: { error: err instanceof Error ? err.message : String(err) } }; } + +type ErrorContext = { error: unknown; set: { status?: number | string } }; + +/** + * Elysia raises its own errors (validation, parse, unmatched route) around the + * handler rather than inside it, so they were unreachable from the per-route + * `try`/`catch` this hook replaces. Returning `undefined` leaves them to + * Elysia's own rendering — mapping them would turn a 422 into a 500. + */ +export function errorHook(map: (err: unknown) => { status: number; body: { error: string } }) { + return ({ error, set }: ErrorContext) => { + if ( + error instanceof ValidationError || + error instanceof NotFoundError || + error instanceof ParseError || + error instanceof InvalidCookieSignature || + error instanceof InvalidFileType + ) { + return; + } + const mapped = map(error); + set.status = mapped.status; + return mapped.body; + }; +} + +export const mapErrorHook = errorHook(mapError); diff --git a/packages/fleet-ship/src/api/workspaces.ts b/packages/fleet-ship/src/api/workspaces.ts index 509bf4e..a0c7f45 100644 --- a/packages/fleet-ship/src/api/workspaces.ts +++ b/packages/fleet-ship/src/api/workspaces.ts @@ -16,7 +16,7 @@ import { import type { ServerMsg } from "webterm/protocol"; import type { WorkspaceManager } from "../workspace-manager"; import { WORKSPACE_TMUX_NAMESPACE } from "../workspace-session"; -import { mapError } from "./http"; +import { mapErrorHook } from "./http"; // One terminal connection per workspace session — guards against two browser // tabs racing to attach the same tmux session through separate PTYs. The value @@ -44,7 +44,7 @@ function bufferedAmount(ws: unknown): number { return raw?.getBufferedAmount?.() ?? 0; } -export const TERMINAL_INIT_TIMEOUT_MS = 5_000; +const TERMINAL_INIT_TIMEOUT_MS = 5_000; export const TERMINAL_INIT_TIMEOUT_CLOSE_CODE = 1008; export const TERMINAL_INIT_TIMEOUT_CLOSE_REASON = "terminal init timeout"; @@ -70,24 +70,19 @@ export function workspacesPlugin( initTimeoutMs = TERMINAL_INIT_TIMEOUT_MS, ) { return new Elysia({ name: "ship-workspaces" }) + .onError(mapErrorHook) .get( "/workspaces", - async ({ query, set }) => { - try { - const active = - query.active === undefined - ? undefined - : query.active === "true" - ? "active" - : query.active === "false" - ? "inactive" - : undefined; - return await manager.list(active); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + ({ query }) => { + const active = + query.active === undefined + ? undefined + : query.active === "true" + ? "active" + : query.active === "false" + ? "inactive" + : undefined; + return manager.list(active); }, { query: t.Object({ @@ -95,34 +90,19 @@ export function workspacesPlugin( }), }, ) - .get("/workspaces/:repo/:name", async ({ params, set }) => { - try { - return await manager.get(params.repo, params.name); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }) + .get("/workspaces/:repo/:name", ({ params }) => manager.get(params.repo, params.name)) .get( "/workspaces/:repo/:name/diff", - async ({ params, query, set }) => { - try { - return await manager.diff(params.repo, params.name, { - staged: query.staged, - stat: query.stat, - nameOnly: query.nameOnly, - range: query.range, - mergeBase: query.mergeBase, - paths: query.paths, - includeUntracked: query.includeUntracked, - }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => + manager.diff(params.repo, params.name, { + staged: query.staged, + stat: query.stat, + nameOnly: query.nameOnly, + range: query.range, + mergeBase: query.mergeBase, + paths: query.paths, + includeUntracked: query.includeUntracked, + }), { query: t.Object({ staged: t.Optional(t.Boolean()), @@ -137,15 +117,7 @@ export function workspacesPlugin( ) .get( "/workspaces/:repo/:name/refs", - async ({ params, query, set }) => { - try { - return await manager.refs(params.repo, params.name, { commits: query.commits }); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, query }) => manager.refs(params.repo, params.name, { commits: query.commits }), { query: t.Object({ commits: t.Optional(t.Number()), @@ -155,14 +127,8 @@ export function workspacesPlugin( .post( "/workspaces", async ({ body, set }) => { - try { - set.status = 201; - return await manager.create(body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + set.status = 201; + return await manager.create(body); }, { body: t.Object({ @@ -175,15 +141,9 @@ export function workspacesPlugin( ) .post( "/workspaces/:repo/:name/branch", - async ({ params, body, set }) => { - try { - await manager.switchBranch(params.repo, params.name, body); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + async ({ params, body }) => { + await manager.switchBranch(params.repo, params.name, body); + return { ok: true as const }; }, { body: t.Object({ @@ -193,15 +153,7 @@ export function workspacesPlugin( ) .post( "/workspaces/:repo/:name/agent/init", - async ({ params, body, set }) => { - try { - return await manager.initAgent(params.repo, params.name, body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, body }) => manager.initAgent(params.repo, params.name, body), { body: t.Object({ model: t.String(), @@ -210,26 +162,12 @@ export function workspacesPlugin( }), }, ) - .get("/workspaces/:repo/:name/agent/status", async ({ params, set }) => { - try { - return manager.agentStatus(params.repo, params.name); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }) + .get("/workspaces/:repo/:name/agent/status", ({ params }) => + manager.agentStatus(params.repo, params.name), + ) .post( "/workspaces/:repo/:name/agent/status", - async ({ params, body, set }) => { - try { - return await manager.updateAgentStatus(params.repo, params.name, body); - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } - }, + ({ params, body }) => manager.updateAgentStatus(params.repo, params.name, body), { body: t.Object({ state: t.UnionEnum(AGENT_STATES), @@ -237,35 +175,17 @@ export function workspacesPlugin( }), }, ) - .post("/workspaces/:repo/:name/activate", async ({ params, set }) => { - try { - await manager.activate(params.repo, params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .post("/workspaces/:repo/:name/activate", async ({ params }) => { + await manager.activate(params.repo, params.name); + return { ok: true as const }; }) - .post("/workspaces/:repo/:name/deactivate", async ({ params, set }) => { - try { - await manager.deactivate(params.repo, params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .post("/workspaces/:repo/:name/deactivate", async ({ params }) => { + await manager.deactivate(params.repo, params.name); + return { ok: true as const }; }) - .delete("/workspaces/:repo/:name", async ({ params, set }) => { - try { - await manager.remove(params.repo, params.name); - return { ok: true as const }; - } catch (err) { - const mapped = mapError(err); - set.status = mapped.status; - return mapped.body; - } + .delete("/workspaces/:repo/:name", async ({ params }) => { + await manager.remove(params.repo, params.name); + return { ok: true as const }; }) .ws("/workspaces/:repo/:name/terminal", { query: t.Object({ From 310699542e01d04c0281acce79acc9057b68b2dd Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Sun, 2 Aug 2026 17:52:31 -0500 Subject: [PATCH 6/6] Delete Store methods with no callers upsertShip, upsertRepo, and deleteShip have no caller anywhere in the repo. Ship removal goes through replaceAllShips, and upsertShip was a pass-through to createShip that nothing invoked. Co-Authored-By: Claude Opus 5 (1M context) --- packages/fleet-bridge/src/store/store.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/fleet-bridge/src/store/store.ts b/packages/fleet-bridge/src/store/store.ts index 1ad6b89..eb29bfe 100644 --- a/packages/fleet-bridge/src/store/store.ts +++ b/packages/fleet-bridge/src/store/store.ts @@ -139,18 +139,10 @@ export class Store { return this.shipCollection.put(ship); } - async upsertShip(ship: Ship): Promise { - return this.createShip(ship); - } - async updateShip(name: string, values: Partial>): Promise { return this.shipCollection.update(name, values); } - async deleteShip(name: string): Promise { - return this.shipCollection.delete(name); - } - async replaceAllShips(ships: Ship[]): Promise { return this.shipCollection.replaceAll(ships); } @@ -169,10 +161,6 @@ export class Store { }); } - async upsertRepo(repo: Repo): Promise { - return this.repoCollection.put(repo); - } - async updateRepo(name: string, values: Partial>): Promise { return this.repoCollection.update(name, values); }