diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index 7c913f4..82e8aa1 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -3,8 +3,8 @@ import { createRequire } from "node:module"; export interface CliOptions { help: boolean; version: boolean; - /** A leading positional subcommand: serve (default), install, or uninstall. */ - command: "serve" | "install" | "uninstall"; + /** A leading positional subcommand: serve (default), install, uninstall, or status. */ + command: "serve" | "install" | "uninstall" | "status"; port?: string; bind?: string; noToken: boolean; @@ -22,7 +22,7 @@ export function parseArgs(argv: string[]): CliOptions { for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] ?? ""; // A leading positional subcommand selects the mode (serve is the default when absent). - if (i === 0 && (arg === "install" || arg === "uninstall")) { + if (i === 0 && (arg === "install" || arg === "uninstall" || arg === "status")) { opts.command = arg; continue; } @@ -55,6 +55,7 @@ export function helpText(): string { " roamcode [options]", " roamcode install Install a per-user login service (launchd/systemd --user).", " roamcode uninstall Print how to remove the service.", + " roamcode status Is the service installed and the server reachable? Which build?", "", "Options:", " --port Port to listen on (default 4280; 0 = pick a free port). Sets PORT.", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 94581fe..85178da 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -99,6 +99,13 @@ export async function run(argv: string[], deps: RunDeps = defaultDeps()): Promis } return 0; } + if (opts.command === "status") { + // Lazy imports, same reason as install: keep the serve path lean. resolveDataDir is the same + // resolution the server itself uses, so status reads the exact service.json/token install wrote. + const { runStatus } = await import("./status.js"); + const { resolveDataDir } = await import("@roamcode/server"); + return runStatus({ dataDir: resolveDataDir(deps.env), env: deps.env, stdout: deps.stdout }); + } if (opts.command === "uninstall") { deps.stdout( "macOS: launchctl unload -w ~/Library/LaunchAgents/com.roamcode.plist && rm ~/Library/LaunchAgents/com.roamcode.plist\n" + diff --git a/packages/cli/src/status.ts b/packages/cli/src/status.ts new file mode 100644 index 0000000..fe8540b --- /dev/null +++ b/packages/cli/src/status.ts @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * `roamcode status` — answer "is the service installed? is the server up? which build?" at a glance, + * without launchctl/systemctl incantations. Three steps: + * + * 1. `/service.json` (written by `roamcode install`) names the installed service, if any. + * 2. GET /health on 127.0.0.1: (PORT env or 4280) with a short timeout — the liveness answer. + * 3. GET /version — a BONUS: it is token-gated, so it only enriches the output when the persisted + * token (or ACCESS_TOKEN) is available; "running" alone is still an honest answer without it. + * + * Exit code: 0 when the server is reachable, 1 when not — so scripts can `roamcode status && …`. + */ + +/** Injectable seams so status is unit-testable with no real network, filesystem, or data dir. */ +export interface StatusDeps { + dataDir: string; + env: NodeJS.ProcessEnv; + stdout: (s: string) => void; + /** Injectable fetch (tests fake the routes). Defaults to the global fetch. */ + fetchFn?: typeof fetch; + /** Injectable file reader (throws like readFileSync when missing). Defaults to readFileSync utf8. */ + readFile?: (path: string) => string; +} + +/** Probe budget per request — long enough for a healthy loopback answer, short enough to feel instant. */ +const PROBE_TIMEOUT_MS = 2000; + +/** The service identity persisted by `roamcode install` (see install.ts writeServiceJson). */ +interface ServiceInfo { + manager: string; + label: string; +} + +/** Parse `/service.json`; undefined when absent or corrupt (status must never throw on it). */ +function readServiceInfo(dataDir: string, readFile: (p: string) => string): ServiceInfo | undefined { + try { + const parsed = JSON.parse(readFile(join(dataDir, "service.json"))) as unknown; + if (parsed && typeof parsed === "object") { + const { manager, label } = parsed as Record; + if (typeof manager === "string" && typeof label === "string") return { manager, label }; + } + } catch { + /* no service installed (or unreadable json) — both read as "not installed" */ + } + return undefined; +} + +/** The token /version wants: explicit ACCESS_TOKEN wins (matches the server's own precedence), + * else the persisted `/token`. Undefined (NO_TOKEN dev, fresh dir) just skips the bonus. */ +function readToken(dataDir: string, env: NodeJS.ProcessEnv, readFile: (p: string) => string): string | undefined { + if (env.ACCESS_TOKEN) return env.ACCESS_TOKEN; + try { + const token = readFile(join(dataDir, "token")).trim(); + return token || undefined; + } catch { + return undefined; + } +} + +/** PORT env when it is a usable positive integer, else the default 4280 (PORT=0 means "pick a free + * port" at serve time — unknowable here, so the default is the best probe target). */ +function resolvePort(env: NodeJS.ProcessEnv): number { + const n = Number(env.PORT); + return Number.isInteger(n) && n > 0 ? n : 4280; +} + +export async function runStatus(deps: StatusDeps): Promise { + const readFile = deps.readFile ?? ((p: string) => readFileSync(p, "utf8")); + const fetchFn = deps.fetchFn ?? fetch; + + // (1) The installed service, if any. + const service = readServiceInfo(deps.dataDir, readFile); + if (service) deps.stdout(`Service: ${service.manager} · ${service.label}\n`); + else deps.stdout("Service: none installed (run `roamcode install` to add one)\n"); + + // (2) Liveness: /health is the unauthenticated probe the server keeps open for exactly this. + const base = `http://127.0.0.1:${resolvePort(deps.env)}`; + let reachable = false; + try { + const res = await fetchFn(`${base}/health`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); + reachable = res.ok; + } catch { + reachable = false; + } + if (!reachable) { + deps.stdout(`Server: not reachable at ${base}\n`); + return 1; + } + + // (3) Best-effort build info — /version is token-gated, so any failure (no token, 401, timeout) + // quietly degrades to plain "running" rather than contradicting the /health answer we already have. + let detail = ""; + const token = readToken(deps.dataDir, deps.env, readFile); + if (token) { + try { + const res = await fetchFn(`${base}/version`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + if (res.ok) { + const v = (await res.json()) as { current?: unknown; runningBuild?: unknown }; + const parts: string[] = []; + if (typeof v.current === "string" && v.current && v.current !== "—") + parts.push(`v${v.current.replace(/^v/, "")}`); + if (typeof v.runningBuild === "string" && v.runningBuild) parts.push(v.runningBuild); + if (parts.length > 0) detail = ` (${parts.join(" · ")})`; + } + } catch { + /* best-effort — reachability was already established */ + } + } + deps.stdout(`Server: running at ${base}${detail}\n`); + return 0; +} diff --git a/packages/cli/test/args.test.ts b/packages/cli/test/args.test.ts index e1b2585..366631e 100644 --- a/packages/cli/test/args.test.ts +++ b/packages/cli/test/args.test.ts @@ -41,6 +41,9 @@ describe("parseArgs", () => { test("uninstall subcommand", () => { expect(parseArgs(["uninstall"]).command).toBe("uninstall"); }); + test("status subcommand", () => { + expect(parseArgs(["status"]).command).toBe("status"); + }); test("a subcommand is only recognized as the leading positional", () => { // `install` after a flag is a non-leading positional → ignored, stays in serve mode. expect(parseArgs(["--no-token", "install"]).command).toBe("serve"); diff --git a/packages/cli/test/status.test.ts b/packages/cli/test/status.test.ts new file mode 100644 index 0000000..e08d1a1 --- /dev/null +++ b/packages/cli/test/status.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test, vi } from "vitest"; +import { runStatus } from "../src/status.js"; +import type { StatusDeps } from "../src/status.js"; + +/** + * Build fully-faked deps: an in-memory "filesystem" (path → contents; missing paths throw like + * readFileSync) and a fetch faked per-route. No real network, ports, or data dir anywhere. + */ +function fakeDeps(opts: { + files?: Record; + env?: NodeJS.ProcessEnv; + health?: boolean | "throw"; + version?: { status: number; body?: unknown }; +}): { deps: StatusDeps; out: string[]; fetched: string[] } { + const out: string[] = []; + const fetched: string[] = []; + const files = opts.files ?? {}; + const readFile = (p: string): string => { + // Normalize separators so the same fake files work on Windows (join uses "\") and POSIX. + const key = p.replaceAll("\\", "/"); + if (key in files) return files[key] as string; + throw new Error(`ENOENT: ${p}`); + }; + const fetchFn = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + fetched.push(url); + if (url.endsWith("/health")) { + if (opts.health === "throw") throw new Error("ECONNREFUSED"); + return { ok: opts.health === true, json: async () => ({ ok: true }) } as Response; + } + if (url.endsWith("/version")) { + const v = opts.version ?? { status: 401 }; + return { ok: v.status === 200, status: v.status, json: async () => v.body } as Response; + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + const deps: StatusDeps = { + dataDir: "/data", + env: opts.env ?? {}, + stdout: (s) => out.push(s), + fetchFn, + readFile, + }; + return { deps, out, fetched }; +} + +describe("roamcode status", () => { + test("no service installed + nothing listening → says so and exits 1", async () => { + const { deps, out } = fakeDeps({ health: "throw" }); + const code = await runStatus(deps); + expect(code).toBe(1); + const text = out.join(""); + expect(text).toContain("none installed"); + expect(text).toContain("roamcode install"); + expect(text).toContain("not reachable at http://127.0.0.1:4280"); + }); + + test("service installed + server up + token → prints manager/label and version · build, exits 0", async () => { + const { deps, out } = fakeDeps({ + files: { + "/data/service.json": JSON.stringify({ manager: "systemd", label: "roamcode" }), + "/data/token": "tok_secret\n", + }, + health: true, + version: { status: 200, body: { current: "0.4.2", runningBuild: "abc1234" } }, + }); + const code = await runStatus(deps); + expect(code).toBe(0); + const text = out.join(""); + expect(text).toContain("Service: systemd · roamcode"); + expect(text).toContain("running at http://127.0.0.1:4280 (v0.4.2 · abc1234)"); + }); + + test("reachable but no token anywhere → still 'running' (no /version call), exits 0", async () => { + const { deps, out, fetched } = fakeDeps({ health: true }); + const code = await runStatus(deps); + expect(code).toBe(0); + expect(out.join("")).toContain("running at http://127.0.0.1:4280"); + expect(fetched.some((u) => u.endsWith("/version"))).toBe(false); + }); + + test("a rejected /version (rotated token → 401) degrades to plain 'running', not an error", async () => { + const { deps, out } = fakeDeps({ + files: { "/data/token": "tok_stale" }, + health: true, + version: { status: 401 }, + }); + const code = await runStatus(deps); + expect(code).toBe(0); + // The newline right after the URL proves no "(v… · sha)" detail was appended. + expect(out.join("")).toContain("running at http://127.0.0.1:4280\n"); + }); + + test("PORT env picks the probe target; ACCESS_TOKEN beats the token file", async () => { + const { deps, out, fetched } = fakeDeps({ + files: { "/data/token": "tok_file" }, + env: { PORT: "5310", ACCESS_TOKEN: "tok_env" }, + health: true, + version: { status: 200, body: { current: "1.0.0", runningBuild: "deadbee" } }, + }); + const code = await runStatus(deps); + expect(code).toBe(0); + expect(out.join("")).toContain("running at http://127.0.0.1:5310"); + expect(fetched.every((u) => u.includes(":5310/"))).toBe(true); + }); + + test("PORT=0 (serve-time 'pick a free port') falls back to probing the default 4280", async () => { + const { deps, fetched } = fakeDeps({ env: { PORT: "0" }, health: true }); + await runStatus(deps); + expect(fetched[0]).toContain(":4280/"); + }); + + test("corrupt service.json reads as 'none installed' (never throws)", async () => { + const { deps, out } = fakeDeps({ + files: { "/data/service.json": "{not json" }, + health: true, + }); + const code = await runStatus(deps); + expect(code).toBe(0); + expect(out.join("")).toContain("none installed"); + }); +});