Skip to content
Open
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
7 changes: 4 additions & 3 deletions packages/cli/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 <n> Port to listen on (default 4280; 0 = pick a free port). Sets PORT.",
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down
116 changes: 116 additions & 0 deletions packages/cli/src/status.ts
Original file line number Diff line number Diff line change
@@ -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. `<dataDir>/service.json` (written by `roamcode install`) names the installed service, if any.
* 2. GET /health on 127.0.0.1:<PORT> (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 `<dataDir>/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<string, unknown>;
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 `<dataDir>/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<number> {
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;
}
3 changes: 3 additions & 0 deletions packages/cli/test/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
122 changes: 122 additions & 0 deletions packages/cli/test/status.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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");
});
});