Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"commander": "latest",
"elysia": "latest",
"fleet-bridge": "workspace:*",
"fleet-cli-kit": "workspace:*",
"fleet-client": "workspace:*",
"fleet-protocol": "workspace:*",
"fleet-ship": "workspace:*",
Expand Down
26 changes: 5 additions & 21 deletions apps/cli/src/attach.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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. */
Expand All @@ -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`;
Expand All @@ -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<void> {
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<number> {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error("fleet: attach requires an interactive terminal");
Expand Down
68 changes: 0 additions & 68 deletions apps/cli/src/client.ts
Original file line number Diff line number Diff line change
@@ -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<typeof treaty<App>>;
export type FleetBridgeClient = ReturnType<typeof treaty<BridgeApp>>;

/** Build an Eden Treaty client pointed at a Fleet Ship `url` (already normalized). */
export function makeClient(url: string): FleetClient {
return treaty<App>(url);
}

/** Build an Eden Treaty client pointed at a Fleet Bridge `url` (already normalized). */
export function makeBridgeClient(url: string): FleetBridgeClient {
return treaty<BridgeApp>(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<T> {
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<T>(result: EdenResult<T>): 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;
}
35 changes: 3 additions & 32 deletions apps/cli/src/format.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,43 @@
/**
* 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"],
rows.map((row) => [row.repoName, row.name, row.branch, row.active ? "yes" : "no"]),
);
}

/** 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"],
rows.map((row) => [row.ship, row.repoName, row.name, row.branch, row.active ? "yes" : "no"]),
);
}

/** Render the bridge's registered ships as a table. */
export function formatShipTable(rows: readonly ShipInfo[]): string {
return renderTable(
["NAME", "URL", "STATUS"],
rows.map((row) => [row.name, row.url, row.status]),
);
}

/** Render the bridge's registered repos as a table. */
export function formatRepoTable(rows: readonly Repo[]): string {
return renderTable(
["NAME", "URL", "PROVIDER"],
rows.map((row) => [row.name, row.url, row.provider]),
);
}

/** 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. */
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";

/**
Expand All @@ -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.
Expand All @@ -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(
Expand All @@ -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[],
Expand Down
Loading
Loading