diff --git a/apps/cli/src/commands/clean.ts b/apps/cli/src/commands/clean.ts index 31d5793..9a40845 100644 --- a/apps/cli/src/commands/clean.ts +++ b/apps/cli/src/commands/clean.ts @@ -5,38 +5,34 @@ import { exec } from "@arbitrum/testnode-core/exec.js"; import { stopCurrentRun } from "@arbitrum/testnode-core/run-logger.js"; import { SNAPSHOTS_DIRNAME } from "@arbitrum/testnode-core/snapshot.js"; import { Cli, z } from "incur"; -import { findProjectRoot } from "../project-root.js"; +import { projectRoot } from "../project-root.js"; -const PROJECT_ROOT = findProjectRoot(); -const CONFIG_DIR = resolve(PROJECT_ROOT, "config"); -const COMPOSE_FILE = resolve(PROJECT_ROOT, "docker/docker-compose.yaml"); - -function stopAllServices(): void { +function stopAllServices(composeFile: string): void { console.log("[clean] Stopping Docker..."); - composeDown({ composeFile: COMPOSE_FILE, projectName: "arbitrum-testnode" }); - exec("docker", ["compose", "-f", COMPOSE_FILE, "-p", "arbitrum-testnode", "down", "-v"]); + composeDown({ composeFile, projectName: "arbitrum-testnode" }); + exec("docker", ["compose", "-f", composeFile, "-p", "arbitrum-testnode", "down", "-v"]); } -function removeConfigDirPreservingSnapshots(): void { - for (const entry of readdirSync(CONFIG_DIR)) { +function removeConfigDirPreservingSnapshots(configDir: string): void { + for (const entry of readdirSync(configDir)) { if (entry === SNAPSHOTS_DIRNAME) { continue; } - rmSync(join(CONFIG_DIR, entry), { recursive: true, force: true }); + rmSync(join(configDir, entry), { recursive: true, force: true }); } } -function cleanConfigDir(purgeSnapshots: boolean): void { - if (!existsSync(CONFIG_DIR)) { +function cleanConfigDir(configDir: string, purgeSnapshots: boolean): void { + if (!existsSync(configDir)) { return; } if (purgeSnapshots) { console.log("[clean] Removing config directory..."); - rmSync(CONFIG_DIR, { recursive: true, force: true }); + rmSync(configDir, { recursive: true, force: true }); return; } console.log("[clean] Removing runtime data and preserving snapshots..."); - removeConfigDirPreservingSnapshots(); + removeConfigDirPreservingSnapshots(configDir); } export const cleanCli = Cli.create("clean", { @@ -48,9 +44,11 @@ export const cleanCli = Cli.create("clean", { .describe("Also delete snapshot bundles under config/snapshots"), }), run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); + const COMPOSE_FILE = resolve(projectRoot(), "docker/docker-compose.yaml"); stopCurrentRun(CONFIG_DIR); - stopAllServices(); - cleanConfigDir(c.options.purgeSnapshots ?? false); + stopAllServices(COMPOSE_FILE); + cleanConfigDir(CONFIG_DIR, c.options.purgeSnapshots ?? false); console.log("[clean] Done."); return { success: true }; }, diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts index 2eb1cda..9477ca1 100644 --- a/apps/cli/src/commands/init.ts +++ b/apps/cli/src/commands/init.ts @@ -1,8 +1,6 @@ import { createInitContext, runInitCommand } from "@arbitrum/testnode-core/init-runner.js"; import { Cli, z } from "incur"; -import { findProjectRoot } from "../project-root.js"; - -const PROJECT_ROOT = findProjectRoot(); +import { projectRoot } from "../project-root.js"; export const initCli = Cli.create("init", { description: "Initialize the testnode (L1 + L2 + L3 with bridges)", @@ -38,6 +36,6 @@ export const initCli = Cli.create("init", { .describe("Deploy Timeboost contracts and restart L2 with Timeboost enabled"), }), async run(c) { - return runInitCommand(c.options, createInitContext(PROJECT_ROOT)); + return runInitCommand(c.options, createInitContext(projectRoot())); }, }); diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 7163f3c..e2ab69d 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -5,10 +5,7 @@ import { readTextLogTail, } from "@arbitrum/testnode-core/run-logger.js"; import { Cli, z } from "incur"; -import { findProjectRoot } from "../project-root.js"; - -const PROJECT_ROOT = findProjectRoot(); -const CONFIG_DIR = resolve(PROJECT_ROOT, "config"); +import { projectRoot } from "../project-root.js"; export const logsCli = Cli.create("logs", { description: "Show the latest init run logs", @@ -17,6 +14,7 @@ export const logsCli = Cli.create("logs", { raw: z.boolean().optional().describe("Read the plain text output log instead of JSON events"), }), run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); const run = loadCurrentRun(CONFIG_DIR); if (!run) { return { success: false, error: "No init run found" }; diff --git a/apps/cli/src/commands/registry.ts b/apps/cli/src/commands/registry.ts deleted file mode 100644 index e934aec..0000000 --- a/apps/cli/src/commands/registry.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Cli as IncurCli } from "incur"; - -export type CommandGroup = "local" | "start"; -export type LoadedCommand = IncurCli.Root & { name: string }; - -export const CLI_METADATA = { - description: "Minimal Arbitrum testnode (L1 + L2 + L3)", - name: "testnode", - version: "0.1.0", -} as const; - -export interface CommandEntry { - group: CommandGroup; - name: string; - summary: string; - load: () => Promise; -} - -export const COMMAND_REGISTRY: CommandEntry[] = [ - { - name: "start", - summary: "Boot the published testnode image from config with one command", - group: "start", - load: async () => (await import("./start.js")).startCli, - }, - { - name: "init", - summary: "Initialize the testnode (L1 + L2 + L3 with bridges)", - group: "local", - load: async () => (await import("./init.js")).initCli, - }, - { - name: "logs", - summary: "Show init run logs", - group: "local", - load: async () => (await import("./logs.js")).logsCli, - }, - { - name: "snapshot", - summary: "Build or restore snapshots", - group: "local", - load: async () => (await import("./snapshot.js")).snapshotCli, - }, - { - name: "status", - summary: "Show service and init state", - group: "local", - load: async () => (await import("./status.js")).statusCli, - }, - { - name: "stop", - summary: "Stop services", - group: "local", - load: async () => (await import("./stop.js")).stopCli, - }, - { - name: "clean", - summary: "Remove containers and saved data", - group: "local", - load: async () => (await import("./clean.js")).cleanCli, - }, -]; - -export function findCommand(name: string | undefined): CommandEntry | undefined { - return COMMAND_REGISTRY.find((command) => command.name === name); -} - -export function commandsInGroup(group: CommandGroup): CommandEntry[] { - return COMMAND_REGISTRY.filter((command) => command.group === group); -} diff --git a/apps/cli/src/commands/snapshot.ts b/apps/cli/src/commands/snapshot.ts index 3603fdf..6b1a0cc 100644 --- a/apps/cli/src/commands/snapshot.ts +++ b/apps/cli/src/commands/snapshot.ts @@ -19,12 +19,11 @@ import { verifySnapshotSemanticState, } from "@arbitrum/testnode-core/snapshot.js"; import { Cli, z } from "incur"; -import { findProjectRoot } from "../project-root.js"; +import { projectRoot } from "../project-root.js"; -const PROJECT_ROOT = findProjectRoot(); -const CONFIG_DIR = resolve(PROJECT_ROOT, "config"); -const COMPOSE_FILE = resolve(PROJECT_ROOT, "docker/docker-compose.yaml"); -const SNAPSHOT_PACK_DIR = resolve(PROJECT_ROOT, "dist/snapshots"); +function snapshotPackDir(): string { + return resolve(projectRoot(), "dist/snapshots"); +} const RPCS = { l1: "http://127.0.0.1:8545", @@ -56,7 +55,9 @@ const snapshotPackOptions = z.object({ outDir: z .string() .optional() - .describe(`Output directory for packaged assets (default: ${SNAPSHOT_PACK_DIR})`), + .describe( + "Output directory for packaged assets (default: dist/snapshots under the project root)", + ), tag: z.string().describe("Release tag used in the packaged asset names"), }); @@ -64,6 +65,8 @@ snapshotCli.command("build", { description: "Capture the current initialized stack into a reusable snapshot", options: snapshotOptions, async run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); + const COMPOSE_FILE = resolve(projectRoot(), "docker/docker-compose.yaml"); const snapshotId = c.options.id ?? DEFAULT_SNAPSHOT_ID; await verifySnapshotSemanticState(CONFIG_DIR, RPCS); stopRuntime({ @@ -99,6 +102,8 @@ snapshotCli.command("restore", { description: "Restore a snapshot and start the stack from it", options: snapshotOptions, async run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); + const COMPOSE_FILE = resolve(projectRoot(), "docker/docker-compose.yaml"); const snapshotId = c.options.id ?? DEFAULT_SNAPSHOT_ID; stopRuntime({ composeFile: COMPOSE_FILE, @@ -133,6 +138,7 @@ snapshotCli.command("verify", { description: "Verify snapshot structure and, if running, semantic bridge state", options: snapshotOptions, async run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); const snapshotId = c.options.id ?? DEFAULT_SNAPSHOT_ID; const manifest = verifySnapshotManifest(CONFIG_DIR, snapshotId); let semanticState: "skipped" | "verified" = "skipped"; @@ -158,6 +164,8 @@ snapshotCli.command("install", { description: "Download and install a snapshot release", options: snapshotInstallOptions, async run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); + const COMPOSE_FILE = resolve(projectRoot(), "docker/docker-compose.yaml"); const result = await installSnapshotRelease({ composeFile: COMPOSE_FILE, configDir: CONFIG_DIR, @@ -183,8 +191,9 @@ snapshotCli.command("pack", { description: "Package a local snapshot into GitHub release assets", options: snapshotPackOptions, run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); const result = packageSnapshotRelease(CONFIG_DIR, { - outDir: c.options.outDir ?? SNAPSHOT_PACK_DIR, + outDir: c.options.outDir ?? snapshotPackDir(), tag: c.options.tag, ...(c.options.id ? { snapshotId: c.options.id } : {}), }); @@ -205,6 +214,7 @@ snapshotCli.command("invalidate", { description: "Remove a snapshot bundle", options: snapshotOptions, run(c) { + const CONFIG_DIR = resolve(projectRoot(), "config"); const snapshotId = c.options.id ?? DEFAULT_SNAPSHOT_ID; return { success: true, @@ -215,5 +225,5 @@ snapshotCli.command("invalidate", { }); export function hasDefaultSnapshot(): boolean { - return hasSnapshot(CONFIG_DIR, DEFAULT_SNAPSHOT_ID); + return hasSnapshot(resolve(projectRoot(), "config"), DEFAULT_SNAPSHOT_ID); } diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 68bdb11..a84fce6 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -7,12 +7,7 @@ import { } from "@arbitrum/testnode-core/run-logger.js"; import { loadState } from "@arbitrum/testnode-core/state.js"; import { Cli } from "incur"; -import { findProjectRoot } from "../project-root.js"; - -const PROJECT_ROOT = findProjectRoot(); -const CONFIG_DIR = resolve(PROJECT_ROOT, "config"); -const COMPOSE_FILE = resolve(PROJECT_ROOT, "docker/docker-compose.yaml"); -const DOCKER_OPTS = { composeFile: COMPOSE_FILE, projectName: "arbitrum-testnode" }; +import { projectRoot } from "../project-root.js"; const RPCS = { l1: "http://127.0.0.1:8545", @@ -23,6 +18,9 @@ const RPCS = { export const statusCli = Cli.create("status", { description: "Show testnode status", run() { + const CONFIG_DIR = resolve(projectRoot(), "config"); + const COMPOSE_FILE = resolve(projectRoot(), "docker/docker-compose.yaml"); + const DOCKER_OPTS = { composeFile: COMPOSE_FILE, projectName: "arbitrum-testnode" }; const state = loadState(CONFIG_DIR); const run = loadCurrentRun(CONFIG_DIR); diff --git a/apps/cli/src/commands/stop.ts b/apps/cli/src/commands/stop.ts index 9cf9e8f..2cd06c0 100644 --- a/apps/cli/src/commands/stop.ts +++ b/apps/cli/src/commands/stop.ts @@ -2,16 +2,14 @@ import { resolve } from "node:path"; import { composeDown } from "@arbitrum/testnode-core/docker.js"; import { stopCurrentRun } from "@arbitrum/testnode-core/run-logger.js"; import { Cli } from "incur"; -import { findProjectRoot } from "../project-root.js"; - -const PROJECT_ROOT = findProjectRoot(); -const CONFIG_DIR = resolve(PROJECT_ROOT, "config"); -const COMPOSE_FILE = resolve(PROJECT_ROOT, "docker/docker-compose.yaml"); -const DOCKER_OPTS = { composeFile: COMPOSE_FILE, projectName: "arbitrum-testnode" }; +import { projectRoot } from "../project-root.js"; export const stopCli = Cli.create("stop", { description: "Stop the testnode (kills Anvil + Docker)", run() { + const CONFIG_DIR = resolve(projectRoot(), "config"); + const COMPOSE_FILE = resolve(projectRoot(), "docker/docker-compose.yaml"); + const DOCKER_OPTS = { composeFile: COMPOSE_FILE, projectName: "arbitrum-testnode" }; if (stopCurrentRun(CONFIG_DIR)) { console.log("[stop] Stopped detached init run..."); } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index b938132..8912f5f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,63 +1,35 @@ #!/usr/bin/env node -import { CLI_METADATA, COMMAND_REGISTRY, findCommand } from "./commands/registry.js"; - -async function run(argv = process.argv.slice(2)): Promise { - const [command] = argv; - - const entry = findCommand(command); - if (entry?.group === "start") { - const { createStartCli } = await import("./start-cli.js"); - await (await createStartCli()).serve(argv); - return; - } - - if (!command || command === "--help" || command === "-h" || command === "help") { - printTopLevelHelp(); - return; - } - - if (command === "--version" || command === "-v") { - process.stdout.write(`${CLI_METADATA.version}\n`); - return; - } - - if (entry?.group === "local") { - const { createLocalCli } = await import("./local-cli.js"); - await (await createLocalCli()).serve(argv); - return; - } - - printTopLevelHelp(); - process.stderr.write(`\nUnknown command: ${command}\n`); - process.exitCode = 1; +import { readFileSync } from "node:fs"; +import { Cli } from "incur"; +import { cleanCli } from "./commands/clean.js"; +import { initCli } from "./commands/init.js"; +import { logsCli } from "./commands/logs.js"; +import { snapshotCli } from "./commands/snapshot.js"; +import { startCli } from "./commands/start.js"; +import { statusCli } from "./commands/status.js"; +import { stopCli } from "./commands/stop.js"; + +const { version } = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as { version: string }; + +export function createCli() { + return Cli.create("testnode", { + description: "Minimal Arbitrum testnode (L1 + L2 + L3)", + version, + sync: { suggestions: ["boot a testnode with start", "capture a snapshot"] }, + mcp: { agents: ["claude-code"] }, + }) + .command(startCli) + .command(initCli) + .command(logsCli) + .command(snapshotCli) + .command(statusCli) + .command(stopCli) + .command(cleanCli); } -function printTopLevelHelp(): void { - process.stdout.write(`${CLI_METADATA.description} - -Usage: - ${CLI_METADATA.name} [options] - -Commands: -${formatCommandHelp()} - -Options: - -h, --help Show help - -v, --version Show version -`); +if (import.meta.url === `file://${process.argv[1]}`) { + createCli().serve(); } - -function formatCommandHelp(): string { - const width = Math.max(...COMMAND_REGISTRY.map((command) => command.name.length)); - return COMMAND_REGISTRY.map( - (command) => ` ${command.name.padEnd(width)} ${command.summary}`, - ).join("\n"); -} - -run().catch((error) => { - process.stderr.write( - `${error instanceof Error ? error.stack || error.message : String(error)}\n`, - ); - process.exitCode = 1; -}); diff --git a/apps/cli/src/local-cli.ts b/apps/cli/src/local-cli.ts deleted file mode 100644 index 7cc76cb..0000000 --- a/apps/cli/src/local-cli.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Cli } from "incur"; -import { CLI_METADATA, commandsInGroup } from "./commands/registry.js"; - -export async function createLocalCli() { - const cli = Cli.create(CLI_METADATA.name, { - description: CLI_METADATA.description, - version: CLI_METADATA.version, - }); - - for (const command of commandsInGroup("local")) { - cli.command(await command.load()); - } - - return cli; -} diff --git a/apps/cli/src/project-root.ts b/apps/cli/src/project-root.ts index 6f69ee5..0d23919 100644 --- a/apps/cli/src/project-root.ts +++ b/apps/cli/src/project-root.ts @@ -17,3 +17,11 @@ export function findProjectRoot(startDir = import.meta.dirname): string { current = parent; } } + +let cached: string | undefined; +export function projectRoot(): string { + if (cached === undefined) { + cached = findProjectRoot(); + } + return cached; +} diff --git a/apps/cli/src/start-cli.ts b/apps/cli/src/start-cli.ts deleted file mode 100644 index dd29665..0000000 --- a/apps/cli/src/start-cli.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Cli } from "incur"; -import { CLI_METADATA, commandsInGroup } from "./commands/registry.js"; - -export async function createStartCli() { - const cli = Cli.create(CLI_METADATA.name, { - description: CLI_METADATA.description, - version: CLI_METADATA.version, - }); - for (const command of commandsInGroup("start")) { - cli.command(await command.load()); - } - return cli; -} diff --git a/apps/cli/test/skills.test.ts b/apps/cli/test/skills.test.ts new file mode 100644 index 0000000..7ee4c89 --- /dev/null +++ b/apps/cli/test/skills.test.ts @@ -0,0 +1,11 @@ +import { Cli } from "incur"; +import { describe, expect, it } from "vitest"; +import { createCli } from "../src/index.js"; + +describe("root cli", () => { + it("registers every top-level command", () => { + const commands = Cli.toCommands.get(createCli()); + const names = [...(commands?.keys() ?? [])].sort(); + expect(names).toEqual(["clean", "init", "logs", "snapshot", "start", "status", "stop"].sort()); + }); +});