diff --git a/README.md b/README.md index 369c225..4d68f9f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ await peer.stop(); Every release is also mirrored to the GitHub Packages registry as `@exadev/cc-peer` (GitHub Packages requires owner-scoped names), and single-executable binaries ship as release assets for every platform/architecture pair Node's own SEA feature supports (see Limitations for the one exception). +`npx cc-peer --help` prints the options: `--port` and `--token` choose the listening port and bearer token, `--no-token` disables authentication, `--name` sets the peer name, and `--home` overrides the home directory holding `.claude`. An unknown option exits non-zero without starting anything. + The REST facade (`npx cc-peer`) serves `GET /sessions`, `POST /messages`, `POST /idle-subscriptions`, `GET /events` (SSE), and a self-describing `GET /openapi.json` on loopback with a bearer token. ### Session discovery and reply aliases diff --git a/src/bin/main.ts b/src/bin/main.ts index eec476e..2e14354 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -11,32 +11,96 @@ export interface CliArgs { noToken: boolean; } -export function parseArgs(argv: readonly string[]): CliArgs { +/** Outcome of parsing the command line: run the facade, print usage, or reject the arguments. */ +export type CliCommand = + | { kind: "run"; args: CliArgs } + | { kind: "help" } + | { kind: "error"; message: string }; + +export const USAGE = `Usage: cc-peer [options] + +Starts the cc-peer REST facade on loopback and registers a peer for this process. + +Options: + --port Port to listen on (default: an ephemeral port) + --token Bearer token to require (default: a generated token) + --no-token Disable bearer-token authentication + --name Peer name shown to Claude Code sessions + --home Home directory containing .claude (default: the current user's) + -h, --help Print this help and exit +`; + +const MAX_PORT = 65_535; + +const VALUE_FLAGS: ReadonlySet = new Set([ + "--port", + "--token", + "--name", + "--home", +]); + +/** + * Parses command-line arguments (without the node and script entries) without any side effects. + * `-h` and `--help` win over everything else; an unknown argument, a value flag with no value, or a + * `--port` that is not an integer from 0 to 65535 yields an error command. + */ +export function parseArgs(argv: readonly string[]): CliCommand { + if (argv.includes("--help") || argv.includes("-h")) { + return { kind: "help" }; + } const args: CliArgs = { noToken: false }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; - const next = argv[i + 1]; - if (arg === "--port" && next !== undefined) { - args.port = Number.parseInt(next, 10); - i += 1; - } else if (arg === "--token" && next !== undefined) { - args.token = next; - i += 1; - } else if (arg === "--name" && next !== undefined) { - args.name = next; - i += 1; - } else if (arg === "--home" && next !== undefined) { - args.home = next; - i += 1; - } else if (arg === "--no-token") { + if (arg === undefined) continue; + if (arg === "--no-token") { args.noToken = true; + continue; + } + if (!VALUE_FLAGS.has(arg)) { + return { kind: "error", message: `unknown argument: ${arg}` }; + } + const value = argv[i + 1]; + if (value === undefined) { + return { kind: "error", message: `${arg} requires a value` }; + } + i += 1; + if (arg === "--port") { + const port = Number(value); + if (!/^\d+$/.test(value) || port > MAX_PORT) { + return { kind: "error", message: `invalid --port: ${value}` }; + } + args.port = port; + } else if (arg === "--token") { + args.token = value; + } else if (arg === "--name") { + args.name = value; + } else { + args.home = value; } } - return args; + return { kind: "run", args }; } -export async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); +/** + * Runs the CLI. Arguments are parsed before any peer is created or socket bound, so `--help` and + * argument errors have no side effects beyond output; an argument error sets a non-zero exit code. + */ +export async function main( + argv: readonly string[] = process.argv.slice(2), +): Promise { + const command = parseArgs(argv); + if (command.kind === "help") { + process.stdout.write(USAGE); + return; + } + if (command.kind === "error") { + process.stderr.write( + `[cc-peer] ${command.message}\nRun 'cc-peer --help' for usage.\n`, + ); + process.exitCode = 2; + return; + } + const { args } = command; const peer = await CcPeer.create({ ...(args.name !== undefined ? { name: args.name } : {}), diff --git a/src/bin/main.unit.test.ts b/src/bin/main.unit.test.ts new file mode 100644 index 0000000..27ff6f0 --- /dev/null +++ b/src/bin/main.unit.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "vitest"; + +import { parseArgs } from "./main.js"; + +describe("parseArgs", () => { + test("parses every option into run args", () => { + expect( + parseArgs([ + "--port", + "8080", + "--token", + "t", + "--name", + "n", + "--home", + "/h", + "--no-token", + ]), + ).toEqual({ + kind: "run", + args: { + port: 8080, + token: "t", + name: "n", + home: "/h", + noToken: true, + }, + }); + }); + + test("no arguments runs with defaults", () => { + expect(parseArgs([])).toEqual({ kind: "run", args: { noToken: false } }); + }); + + test.each([["--help"], ["-h"]])("%s requests help", (flag) => { + expect(parseArgs([flag])).toEqual({ kind: "help" }); + }); + + test("help wins over other arguments, including unknown ones", () => { + expect(parseArgs(["--port", "1", "--bogus", "--help"])).toEqual({ + kind: "help", + }); + }); + + test("rejects an unknown argument", () => { + expect(parseArgs(["--bogus"])).toEqual({ + kind: "error", + message: "unknown argument: --bogus", + }); + }); + + test.each([["--port"], ["--token"], ["--name"], ["--home"]])( + "rejects %s with no value", + (flag) => { + expect(parseArgs([flag])).toEqual({ + kind: "error", + message: `${flag} requires a value`, + }); + }, + ); + + test.each([["abc"], ["1.5"], ["-1"], ["65536"], [""]])( + "rejects invalid port %j", + (port) => { + expect(parseArgs(["--port", port])).toEqual({ + kind: "error", + message: `invalid --port: ${port}`, + }); + }, + ); +}); diff --git a/test/cli.integration.test.ts b/test/cli.integration.test.ts new file mode 100644 index 0000000..bd9e1b0 --- /dev/null +++ b/test/cli.integration.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtemp, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "../src/test/timeouts.js"; + +const ENTRY = fileURLToPath(new URL("../src/bin/cc-peer.ts", import.meta.url)); + +/** Runs the CLI entry in a real process with an empty home, killing it if it fails to exit on its own. */ +async function runCli(args: readonly string[]): Promise<{ + code: number | null; + stdout: string; + stderr: string; + home: string; +}> { + const home = await mkdtemp(join(tmpdir(), "cc-peer-cli-")); + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--import", "tsx", ENTRY, ...args], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + }, REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS / 2); + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr, home }); + }); + }); +} + +describe("cc-peer CLI argument handling", () => { + test.each([["--help"], ["-h"]])( + "%s prints usage and exits 0 without registering a peer or minting a token", + async (flag) => { + const result = await runCli([flag]); + expect(result.code).toBe(0); + expect(result.stdout).toContain("Usage: cc-peer"); + expect(result.stderr).not.toContain("bearer token"); + expect(result.stderr).not.toContain("listening"); + expect(await readdir(result.home)).toEqual([]); + }, + REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS, + ); + + test( + "an unknown flag exits non-zero with a usage hint and starts nothing", + async () => { + const result = await runCli(["--bogus"]); + expect(result.code).toBe(2); + expect(result.stderr).toContain("unknown argument: --bogus"); + expect(result.stderr).toContain("cc-peer --help"); + expect(result.stderr).not.toContain("bearer token"); + expect(result.stderr).not.toContain("listening"); + expect(await readdir(result.home)).toEqual([]); + }, + REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS, + ); +});