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
79 changes: 77 additions & 2 deletions apps/cli/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
* format.ts — pure formatting helpers for CLI output (no network, no I/O).
*/

import type { WorkspaceSummary } from "fleet-protocol";
import type { ArmoryEntry, ArmorySyncState, WorkspaceSummary } from "fleet-protocol";
import type { Repo } from "fleet-protocol";
import type { ShipInfo, BridgeWorkspaceSummary } from "fleet-bridge/types";
import type { ShipInfo, BridgeWorkspaceSummary, ShipArmoryState } from "fleet-bridge/types";

/**
* Render an aligned, human-readable table: a header row followed by one row per
Expand Down Expand Up @@ -53,3 +53,78 @@ export function formatRepoTable(rows: readonly Repo[]): string {
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";

/**
* `error` outranks the revision comparison: a ship whose last sync failed is
* holding a revision it could not replace, so reporting it as merely "behind"
* would hide the reason it is stuck. `unknown` is the bridge not having been able
* to ask at all, which is distinct from a ship answering that it holds nothing.
*
* `fleet-client`'s `syncStatus` derives the same thing for the web GUI. The two
* packages do not depend on each other, so the duplication is deliberate; keep
* their precedence identical.
*/
export function armoryShipState(bridgeRevision: string, state: ArmorySyncState | null): ArmoryShipState {
if (!state) return "unknown";
if (state.lastError) return "error";
if (!state.revision) return "never";
return state.revision === bridgeRevision ? "in sync" : "behind";
}

/**
* 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.
*/
export function formatTimestamp(at: string | Date | null): string {
if (!at) return MISSING;
const parsed = at instanceof Date ? at : new Date(at);
return Number.isNaN(parsed.getTime()) ? String(at) : parsed.toISOString();
}

/** Modes are normalized to `0o644`/`0o755` before they reach the manifest. */
function formatMode(mode: number): string {
return `0${(mode & 0o777).toString(8).padStart(3, "0")}`;
}

/**
* 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`.
*/
export function formatArmoryTable(rows: readonly ArmoryEntry[]): string {
return renderTable(
["SECTION", "PATH", "SIZE", "MODE"],
rows.map((row) => [row.section, row.path, String(row.size), formatMode(row.mode)]),
);
}

/** Render each ship's armory state against the bridge's current `bridgeRevision`. */
export function formatArmoryShipTable(
bridgeRevision: string,
rows: readonly ShipArmoryState[],
): string {
return renderTable(
["SHIP", "STATUS", "REVISION", "SYNCED", "STATE"],
rows.map((row) => [
row.ship,
row.status,
abbreviateRevision(row.state?.revision ?? null),
formatTimestamp(row.state?.syncedAt ?? null),
armoryShipState(bridgeRevision, row.state ?? null),
]),
);
}
104 changes: 102 additions & 2 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,22 @@
*/

import { Command } from "commander";
import { DEFAULT_PORT, type Repo, type WorkspaceStatus, type WorkspaceSummary } from "fleet-protocol";
import type { ShipInfo, BridgeWorkspaceSummary } from "fleet-bridge/types";
import {
ARMORY_SECTIONS,
DEFAULT_PORT,
type ArmoryFile,
type ArmoryManifest,
type ArmorySection,
type Repo,
type WorkspaceStatus,
type WorkspaceSummary,
} from "fleet-protocol";
import type { ShipInfo, BridgeWorkspaceSummary, ShipArmoryState } from "fleet-bridge/types";
import { makeBridgeClient, makeClient, normalizeUrl, unwrap } from "./client";
import {
abbreviateRevision,
formatArmoryShipTable,
formatArmoryTable,
formatFleetWorkspaceTable,
formatRepoTable,
formatShipTable,
Expand Down Expand Up @@ -255,6 +267,94 @@ reposCommand

clientCommand.addCommand(reposCommand);

const armoryCommand = new Command()
.name("armory")
.description("inspect the fleet's armory (via the bridge); read-only");

armoryCommand
.command("ls")
.description("list the files the bridge's armory holds")
.option("--json", "output as JSON")
.option("--section <section>", `only files in one section (${ARMORY_SECTIONS.join(", ")})`)
.action(async (options: { json?: boolean; section?: string }) => {
const section = options.section;
if (section !== undefined && !ARMORY_SECTIONS.includes(section as ArmorySection)) {
console.error(`fleet: unknown section "${section}"; expected one of: ${ARMORY_SECTIONS.join(", ")}`);
process.exit(1);
}

const manifest = unwrap(await bridgeClient().armory.get()) as ArmoryManifest;
const entries = section
? manifest.entries.filter((entry) => entry.section === section)
: manifest.entries;

if (options.json) {
console.log(JSON.stringify({ ...manifest, entries }, null, 2));
} else if (entries.length === 0) {
console.log("no armory files");
} else {
console.log(
`revision ${abbreviateRevision(manifest.revision)} (${entries.length} file${entries.length === 1 ? "" : "s"})`,
);
console.log(formatArmoryTable(entries));
}
});

armoryCommand
.command("cat")
.description("print an armory file's contents")
.argument("<path>", "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;

// Binary bytes re-encoded through stdout would arrive mangled, and a
// redirect would capture that silently — refuse rather than hand back a
// corrupt file.
if (file.encoding === "base64") {
console.error(
`fleet: ${file.path} is binary (${file.size} bytes, sha256 ${file.sha256}); not writing it to stdout`,
);
process.exit(1);
}

process.stdout.write(file.contents);
});

armoryCommand
.command("ships")
.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[];
if (options.json) {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log("no ships");
return;
}

const manifest = unwrap(await bridgeClient().armory.get()) as ArmoryManifest;
console.log(formatArmoryShipTable(manifest.revision, rows));

for (const row of rows) {
const install = row.state?.install;
const problems = [
...(row.state?.lastError ? [`error: ${row.state.lastError}`] : []),
...(install?.conflicts ?? []).map((conflict) => `conflict: ${conflict}`),
...(install?.warnings ?? []).map((warning) => `warning: ${warning}`),
];
if (problems.length === 0) continue;

console.log("");
console.log(`${row.ship}:`);
for (const problem of problems) console.log(` ${problem}`);
}
});

clientCommand.addCommand(armoryCommand);

clientCommand
.command("serve")
.description("Serve the client web ui")
Expand Down
41 changes: 39 additions & 2 deletions apps/cli/src/launch-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,49 @@ import { startBridge } from "fleet-bridge";
import { startShip } from "fleet-ship";
import { startClientServer } from "fleet-client";
import { normalizeUrl } from "./client";
import { CONFIG_TEMPLATE, loadLaunchConfig } from "./launch-config";
import { CONFIG_TEMPLATE, loadLaunchConfig, publicUrlWarning } from "./launch-config";

const DEFAULT_CONFIG_PATH = "./fleet-config.yaml";

async function runLaunch(configPath: string): Promise<void> {
const config = await loadLaunchConfig(configPath);

const warning = publicUrlWarning(config);
if (warning) {
console.warn(`fleet launch: ${warning}`);
}

let manager: Awaited<ReturnType<typeof startBridge>>["manager"] | undefined;
if (config.bridge) {
({ manager } = await startBridge(config.bridge));
}

// A launch knows both sides, so it can pin each ship it spawns to the bridge
// it just started rather than leaving it to trust whoever pushes first. The
// value must be the one the bridge pushes with, not the one this process would
// dial, hence `publicUrl` and the same fallback the bridge uses.
const launchedBridgeUrl = config.bridge
? (config.bridge.publicUrl ?? `http://localhost:${config.bridge.port}`)
: undefined;
if (launchedBridgeUrl && !isHttpUrl(launchedBridgeUrl)) {
// A ship refuses a pin that is not an http(s) URL. Failing the whole launch
// over a `bridge.publicUrl` that previously only broke the armory would be a
// worse trade than starting unpinned and saying so.
console.warn(
`fleet launch: bridge.publicUrl "${launchedBridgeUrl}" is not an http(s) URL, so ships are ` +
"started unpinned and will accept the first armory push they receive",
);
}
const shipBridgeUrl = launchedBridgeUrl && isHttpUrl(launchedBridgeUrl) ? launchedBridgeUrl : undefined;

for (const ship of config.ships) {
if (ship.source === "local") {
await startShip({ fleetDirectory: ship.fleetDirectory, port: ship.port, name: ship.name });
await startShip({
fleetDirectory: ship.fleetDirectory,
port: ship.port,
name: ship.name,
bridgeUrl: shipBridgeUrl,
});
}

const url = ship.source === "local" ? `http://localhost:${ship.port}` : ship.url;
Expand All @@ -48,6 +76,15 @@ async function runLaunch(configPath: string): Promise<void> {
}
}

function isHttpUrl(value: string): boolean {
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}

async function runInit(configPath: string, force: boolean): Promise<void> {
const file = Bun.file(configPath);
if (!force && (await file.exists())) {
Expand Down
34 changes: 34 additions & 0 deletions apps/cli/src/launch-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ const BridgeSectionSchema = z.object({
dataDirectory: z.string().min(1).default(DEFAULT_BRIDGE_DATA_DIRECTORY),
port: z.number().int().default(DEFAULT_BRIDGE_PORT),
name: z.string().min(1).default(DEFAULT_BRIDGE_NAME),
/**
* URL *ships* use to reach this bridge — it is handed to each ship so it can
* pull the armory, so it must resolve from the ships' hosts, not only from the
* one running the launch. Omitted, the bridge falls back to
* `http://localhost:<port>`, which is right for a single-host fleet and wrong
* for any ship on another machine.
*/
publicUrl: z.string().min(1).optional(),
});

const GuiSectionSchema = z.object({
Expand Down Expand Up @@ -79,6 +87,7 @@ export interface NormalizedBridge {
dataDirectory: string;
port: number;
name: string;
publicUrl?: string;
}

export interface NormalizedLocalShip {
Expand Down Expand Up @@ -147,6 +156,30 @@ export function parseLaunchConfig(raw: unknown): NormalizedLaunchConfig {
return { bridge, gui: parsed.gui, ships };
}

/**
* The warning a config earns by registering ships on other hosts without telling
* them how to reach this bridge, or `null` when there is nothing to say.
*
* Deliberately not an error: a `source: remote` ship can be on this very host
* (behind a tunnel, in a container publishing a port), where the
* `http://localhost:<port>` fallback resolves fine. But when it is wrong it fails
* silently — the ship is registered, workspaces work, and only the armory never
* arrives — so it is worth saying out loud.
*/
export function publicUrlWarning(config: NormalizedLaunchConfig): string | null {
if (!config.bridge || config.bridge.publicUrl) return null;

const remote = config.ships.filter((ship) => ship.source === "remote");
if (remote.length === 0) return null;

const names = remote.map((ship) => `"${ship.key}"`).join(", ");
return (
`bridge.publicUrl is not set, so remote ${remote.length === 1 ? "ship" : "ships"} ${names} will be ` +
`told this bridge is at http://localhost:${config.bridge.port}, which on their hosts is themselves; ` +
`set bridge.publicUrl to a URL those hosts can reach`
);
}

/** 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.
Expand All @@ -156,6 +189,7 @@ bridge:
dataDirectory: ./.fleet/bridge
port: 4800
name: my-fleet-bridge
# publicUrl: http://this-host:4800 # how ships reach this bridge; required if any ship is on another host

# The web gui. Proxies to the bridge above by default.
gui:
Expand Down
Loading
Loading