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 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 5deb776..4f84fcf 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, @@ -19,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. */ @@ -33,7 +22,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,20 +48,16 @@ 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; + 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"); } } -/** - * 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..4a75aa3 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -1,76 +1,8 @@ -/** - * 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"; 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); -} - -/** - * 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}`; -} - -/** Shape every Eden Treaty call resolves to. */ -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 7cc0285..1d45bdd 100644 --- a/apps/cli/src/format.ts +++ b/apps/cli/src/format.ts @@ -1,28 +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"; +import { renderTable } from "fleet-cli-kit"; -/** - * 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. - */ -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"); -} - -/** 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 +10,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 +17,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 +24,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 +31,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 +38,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 +58,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 +74,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 +84,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..664af0f 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 { @@ -19,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, @@ -69,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) { @@ -80,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) { @@ -97,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}`); @@ -126,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}`); }); @@ -139,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}`); }); @@ -151,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}`); }); @@ -163,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}`); }); @@ -186,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}`); }); @@ -198,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) { @@ -213,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})`); }); @@ -222,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}`); }); @@ -235,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) { @@ -252,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})`); }); @@ -261,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}`); }); @@ -283,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; @@ -305,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 @@ -325,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; @@ -335,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 247611f..9245c01 100644 --- a/apps/cli/src/launch-command.ts +++ b/apps/cli/src/launch-command.ts @@ -1,16 +1,8 @@ -/** - * 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"; 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/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/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/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/format.ts b/apps/fagent/src/format.ts index 9102ff8..0b68b85 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, @@ -12,33 +6,17 @@ import type { PullRequestSummary, RepoInfo, } from "fleet-bridge/providers"; +import { renderTable } from "fleet-cli-kit"; /** - * 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. - */ -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"); -} - -/** - * 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 +31,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 +44,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 +58,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 +72,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 +84,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..528421d 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, @@ -20,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, @@ -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; @@ -104,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)); }); @@ -116,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)); }); @@ -126,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)); }); @@ -136,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}`); }); @@ -150,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)); }); @@ -160,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)); }); @@ -170,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}`); }); @@ -198,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}`); }); @@ -210,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)); }); @@ -222,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/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/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/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..04a6f84 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; @@ -370,23 +341,23 @@ 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(); } - // --- DCS ---------------------------------------------------------------- - #dcsEntry(b: number): void { if (b === 0x7f) return; if (b >= 0x40 && b <= 0x7e) return this.#dcsHook(b); @@ -454,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; } @@ -482,8 +447,6 @@ export class Parser { } } - // --- OSC ---------------------------------------------------------------- - #oscString(b: number): void { if (b === 0x07) { // BEL terminator @@ -526,8 +489,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 +497,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 +504,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 +517,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..7616df6 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; @@ -76,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) { @@ -95,8 +76,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 +94,6 @@ export class Screen { return row; } - // --- printing ----------------------------------------------------------- - print(cp: number): void { const w = wcwidth(cp); @@ -145,7 +122,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 +158,6 @@ export class Screen { } } - // --- cursor movement ---------------------------------------------------- - #index(): void { // Line feed within the scroll region. if (this.cursor.y === this.scrollBottom) { @@ -271,8 +245,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,14 +259,7 @@ 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(); - } + this.#shiftRowsUp(top, bottom, count); } /** Scroll the scroll region down by `n` lines (content moves down). */ @@ -304,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]!; } @@ -332,18 +310,11 @@ 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; 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; } @@ -352,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; } @@ -391,8 +357,6 @@ export class Screen { this.cursor.pendingWrap = false; } - // --- erasing ------------------------------------------------------------ - eraseLine(mode: number): void { const row = this.grid[this.cursor.y]!; let from = 0; @@ -403,31 +367,29 @@ 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; } - // --- tabs --------------------------------------------------------------- - tab(n = 1): void { for (let i = 0; i < n; i++) { let x = this.cursor.x + 1; @@ -455,8 +417,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,15 +452,12 @@ 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. + 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) { @@ -515,14 +472,11 @@ 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; } - // --- reset -------------------------------------------------------------- - reset(): void { if (this.onAlt) this.leaveAlt(false); this.grid = Array.from({ length: this.rows }, () => makeRow(this.cols)); @@ -543,8 +497,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..838f2bc 100644 --- a/packages/bun-vt/src/sgr.ts +++ b/packages/bun-vt/src/sgr.ts @@ -1,34 +1,21 @@ -/** - * 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"; -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; } @@ -50,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) { @@ -166,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 } { @@ -175,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/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/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-bridge/src/api/armory.ts b/packages/fleet-bridge/src/api/armory.ts index 9e356f5..9bf2aee 100644 --- a/packages/fleet-bridge/src/api/armory.ts +++ b/packages/fleet-bridge/src/api/armory.ts @@ -1,38 +1,13 @@ -/** - * 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"; +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 a510c47..e63d107 100644 --- a/packages/fleet-bridge/src/api/http.ts +++ b/packages/fleet-bridge/src/api/http.ts @@ -1,10 +1,4 @@ -/** - * 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 { InvalidCookieSignature, InvalidFileType, NotFoundError, ParseError, ValidationError } from "elysia"; import { BridgeError } from "../fleet-manager"; import { ProviderError } from "../providers"; @@ -14,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 a47f445..f10cd49 100644 --- a/packages/fleet-bridge/src/api/index.ts +++ b/packages/fleet-bridge/src/api/index.ts @@ -1,15 +1,6 @@ -/** - * 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"; -import type { BridgeConfig } from "../config"; import { workspacesPlugin } from "./workspaces"; import { shipsPlugin } from "./ships"; import { systemResourcesPlugin } from "./system-resources"; @@ -18,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 f5055a8..8d90d8b 100644 --- a/packages/fleet-bridge/src/api/repos.ts +++ b/packages/fleet-bridge/src/api/repos.ts @@ -1,35 +1,16 @@ -/** - * 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"; +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({ @@ -39,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, @@ -160,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 }, ); } @@ -189,12 +88,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..a92f892 100644 --- a/packages/fleet-bridge/src/api/ships.ts +++ b/packages/fleet-bridge/src/api/ships.ts @@ -1,37 +1,21 @@ -/** - * 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"; +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 b0b3a27..6bcef5c 100644 --- a/packages/fleet-bridge/src/api/system-resources.ts +++ b/packages/fleet-bridge/src/api/system-resources.ts @@ -1,23 +1,10 @@ -/** - * 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"; +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 396c2de..3cc7a36 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, @@ -19,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()), @@ -80,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()), @@ -98,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({ @@ -118,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({ @@ -181,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; @@ -218,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); @@ -253,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/armory/armory-service.ts b/packages/fleet-bridge/src/armory/armory-service.ts index b4c8e32..4f9cb70 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 { @@ -34,11 +12,11 @@ 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; -/** Names never worth shipping, skipped wherever they appear in the tree. */ const IGNORED_NAMES = new Set([".git", ".DS_Store"]); export class ArmoryPathError extends Error { @@ -83,32 +61,21 @@ 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; - } - - /** The current manifest, scanning only when the cache is cold. */ 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; }); } - /** Drop the cached manifest so the next `manifest()` rescans. */ invalidate(): void { this.cached = undefined; } @@ -159,8 +126,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..d7010bf 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(); @@ -325,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, @@ -347,10 +313,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 +381,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 +393,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 +429,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 +446,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 +491,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 +690,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 +703,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 +810,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()) { @@ -932,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/index.ts b/packages/fleet-bridge/src/index.ts index d60763e..43c81b5 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 }> { @@ -42,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/src/providers/github.ts b/packages/fleet-bridge/src/providers/github.ts index f4ed2ae..0565b52 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)); @@ -346,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", @@ -357,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"; } @@ -381,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/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..7252c9a 100644 --- a/packages/fleet-bridge/src/providers/provider.ts +++ b/packages/fleet-bridge/src/providers/provider.ts @@ -1,18 +1,7 @@ /** - * 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. + * Mirrors — but deliberately never imports — the manager's `BridgeError`, so the provider layer stays free of the HTTP framework. */ - -/** An HTTP-shaped provider failure; `status` is the status a route should surface. */ export class ProviderError extends Error { readonly status: number; @@ -76,7 +65,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/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/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..eb29bfe 100644 --- a/packages/fleet-bridge/src/store/store.ts +++ b/packages/fleet-bridge/src/store/store.ts @@ -1,8 +1,8 @@ -/** 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"; +import type { z } from "zod"; +import { SerialQueue } from "../serial-queue"; type Persist = (target: string, contents: string) => Promise; @@ -13,171 +13,172 @@ 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; + }); } - private persistRepos(repos: Map): Promise { - return this.persist(join(this.dataDirectory, "repos.json"), stringify([...repos.values()])); + 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; + }); } - async getAllShips(): Promise { - return this.serialized(() => [...this.ships.values()]); + private write(map: Map): Promise { + return this.persist(this.target, stringify([...map.values()])); } +} - async getShip(name: string): Promise { - return this.serialized(() => this.ships.get(name)); +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, + ); } - 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; + 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 upsertShip(ship: Ship): Promise { - return this.createShip(ship); + async getAllShips(): Promise { + return this.shipCollection.getAll(); } - 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; - }); + async getShip(name: string): Promise { + return this.shipCollection.get(name); } - 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; - }); + async createShip(ship: Ship): Promise { + return this.shipCollection.put(ship); + } + + async updateShip(name: string, values: Partial>): Promise { + return this.shipCollection.update(name, values); } 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; - }); - } - - 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, (current) => { + if (current.has(repo.name)) throw new RepoAlreadyExistsError(repo.name); }); } 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-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..2d36b36 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"; @@ -60,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(); @@ -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..c6ad75a 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"; @@ -117,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 a0b1abd..dee6d08 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"; @@ -371,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/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..14d7363 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"; @@ -200,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/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..8969aa7 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"; @@ -87,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-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 55% rename from apps/fagent/src/client.ts rename to packages/fleet-cli-kit/src/client.ts index f823512..8b3784a 100644 --- a/apps/fagent/src/client.ts +++ b/packages/fleet-cli-kit/src/client.ts @@ -1,27 +1,19 @@ -/** - * 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); } /** - * 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(/\/+$/, ""); @@ -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; @@ -45,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; @@ -57,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/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/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/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/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..7687d15 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`). */ @@ -81,18 +80,9 @@ export function upgradeBridgeWebSocket( return new Response("Upgrade failed", { status: 500 }); } -export function startClientServer( - bridgeUrl: string, - 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. - */ +export function startClientServer(bridgeUrl: string, port?: number) { 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; @@ -109,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/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/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/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..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-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..758d939 100644 --- a/packages/fleet-protocol/index.ts +++ b/packages/fleet-protocol/index.ts @@ -1,13 +1,4 @@ -/** - * 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, - 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 5f591e6..4f6cf6f 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,19 +39,19 @@ 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("/"); } +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(), - /** Lowercase hex sha256 of the file's bytes. */ - 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(), }); @@ -83,12 +64,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,8 +103,8 @@ export const DotfileMapSchema = z export type DotfileMap = z.infer; export const ArmoryManifestSchema = z.object({ - /** Content address of the whole armory: lowercase hex sha256. */ - revision: z.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256"), + /** Content address of the whole armory. */ + revision: Sha256Hex, /** Every scanned file, sorted by `path`. */ entries: ArmoryEntrySchema.array(), dotfileMap: DotfileMapSchema, @@ -133,7 +112,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"]), @@ -153,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; @@ -178,7 +156,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/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-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..cf0ef18 100644 --- a/packages/fleet-ship/src/api/armory.ts +++ b/packages/fleet-ship/src/api/armory.ts @@ -1,30 +1,10 @@ -/** - * 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"; -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(); @@ -33,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/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..0e7e8c6 100644 --- a/packages/fleet-ship/src/api/http.ts +++ b/packages/fleet-ship/src/api/http.ts @@ -1,9 +1,4 @@ -/** - * 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 { InvalidCookieSignature, InvalidFileType, NotFoundError, ParseError, ValidationError } from "elysia"; import { WorkspaceError } from "../workspace-manager"; export function mapError(err: unknown): { status: number; body: { error: string } } { @@ -12,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/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..a0c7f45 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 { @@ -21,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 @@ -49,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"; @@ -75,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({ @@ -100,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()), @@ -142,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()), @@ -160,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({ @@ -180,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({ @@ -198,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(), @@ -215,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), @@ -242,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({ diff --git a/packages/fleet-ship/src/armory/armory-cache.ts b/packages/fleet-ship/src/armory/armory-cache.ts index e6ac397..251a013 100644 --- a/packages/fleet-ship/src/armory/armory-cache.ts +++ b/packages/fleet-ship/src/armory/armory-cache.ts @@ -1,36 +1,6 @@ -/** - * 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"; +import { join, resolve } from "node:path"; import { z } from "zod"; import { ArmoryEntrySchema, @@ -46,11 +16,13 @@ import { type ArmorySyncState, type DotfileMap, } from "fleet-protocol"; +import { isStrictDescendant } from "../contained-path"; -/** 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 +37,6 @@ export async function cachedDotfileMap(cacheDirectory: string): Promise = Promise.resolve(); 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; } @@ -283,12 +251,13 @@ 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; } - /** 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,18 +368,17 @@ 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); } 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); } } @@ -500,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 6dfd82d..6cd73f0 100644 --- a/packages/fleet-ship/src/armory/armory-installer.ts +++ b/packages/fleet-ship/src/armory/armory-installer.ts @@ -1,34 +1,6 @@ -/** - * 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 { 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 { @@ -44,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 = { @@ -77,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 = { @@ -120,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) { @@ -187,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 @@ -365,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 4b866b2..ec6ffa3 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"; @@ -42,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 fe15aaa..b34dc34 100644 --- a/packages/fleet-ship/src/armory/dotfile-linker.ts +++ b/packages/fleet-ship/src/armory/dotfile-linker.ts @@ -1,38 +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. - */ - 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"; @@ -69,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); @@ -105,7 +80,13 @@ export async function linkDotfiles(options: LinkDotfilesOptions): Promise 0) throw new AggregateError(failures, "Failed to link the armory dotfiles"); return report; @@ -150,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; @@ -252,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/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/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/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..21033e8 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, @@ -114,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"; } @@ -811,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, @@ -926,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, @@ -1063,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 9445428..76fda39 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 { @@ -22,12 +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; - -/** Display order for `doctor`, matching PROVIDERS. */ -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> = { @@ -92,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) }; @@ -124,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 b835bf5..71e2a74 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" }; @@ -69,8 +45,6 @@ export type InstallFleetPluginOptions = { force?: boolean; }; -export type InspectFleetPluginOptions = InstallFleetPluginOptions; - type FileMapping = { contents: () => Promise; destination: string; @@ -86,7 +60,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 +135,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,12 +256,11 @@ 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 = {}, + 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 7319746..f2719d2 100644 --- a/packages/fleet-ship/src/providers.ts +++ b/packages/fleet-ship/src/providers.ts @@ -1,20 +1,8 @@ -/** - * 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"; -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 cad372f..1fadac5 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. @@ -46,8 +38,6 @@ export type InstallFleetSkillOptions = { force?: boolean; }; -export type InspectFleetSkillOptions = InstallFleetSkillOptions; - type ProviderPaths = { provider: Provider; configRoot: string; @@ -70,7 +60,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,12 +129,9 @@ 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 = {}, + 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 b857057..df20ace 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"; @@ -51,8 +42,6 @@ export class WorkspaceError extends Error { } } -export type CreateWorkspaceOptions = CreateWorkspaceRequest; - export interface SwitchBranchOptions { readonly branch: string; } @@ -185,18 +174,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); @@ -209,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; } } @@ -394,7 +375,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; @@ -422,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/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/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 62df9a2..ca8133d 100644 --- a/packages/git-bun/src/backend.ts +++ b/packages/git-bun/src/backend.ts @@ -1,23 +1,12 @@ -// 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. +import type { Backend, RunResult } from "cli-bun"; -/** Raw result of a single `git` invocation. */ -export interface GitRunResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} +export type GitRunResult = RunResult; /** - * 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; -} +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 ad8d428..1a063a6 100644 --- a/packages/git-bun/src/command.ts +++ b/packages/git-bun/src/command.ts @@ -1,7 +1,7 @@ -import { ShellBackend, type GitBackend, type GitRunResult } from "./backend"; +import { CliCommand } from "cli-bun"; +import { ShellBackend, type GitBackend } 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,44 +23,24 @@ 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 { +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); } /** - * 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[] { + 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 0221d64..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,13 +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; -} - -// --- 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 +32,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 +139,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 +164,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 +186,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/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 b84c77b..fad8d4c 100644 --- a/packages/tmux-bun/src/backend.ts +++ b/packages/tmux-bun/src/backend.ts @@ -1,22 +1,12 @@ -// 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. +import type { Backend, RunResult } from "cli-bun"; -/** Raw result of a single `tmux` invocation. */ -export interface TmuxRunResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} +export type TmuxRunResult = RunResult; /** - * 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; -} +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 a3c6aa3..680be62 100644 --- a/packages/tmux-bun/src/command.ts +++ b/packages/tmux-bun/src/command.ts @@ -1,7 +1,7 @@ -import { ShellBackend, type TmuxBackend, type TmuxRunResult } from "./backend"; +import { CliCommand } from "cli-bun"; +import { ShellBackend, type TmuxBackend } 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,20 +24,21 @@ 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 { +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); } /** @@ -46,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 d37834f..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 @@ -6,15 +7,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 @@ -30,11 +30,6 @@ function parseRows(stdout: string, fields: readonly string[]): Array 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..6b08697 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, @@ -17,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, 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;