diff --git a/packages/cli/src/__tests__/daemon-cli.test.ts b/packages/cli/src/__tests__/daemon-cli.test.ts new file mode 100644 index 0000000..9036790 --- /dev/null +++ b/packages/cli/src/__tests__/daemon-cli.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { daemonCommand } from "../commands/daemon.js"; +import { devCommand } from "../commands/dev.js"; +import { daemonStartCommand } from "../commands/start.js"; + +describe("nerve daemon command group", () => { + it("exposes start, stop, status, restart, and logs subcommands", () => { + const subs = daemonCommand.subCommands; + expect(subs).toBeDefined(); + if (!subs) { + throw new Error("expected daemonCommand.subCommands"); + } + expect(Object.keys(subs).sort()).toEqual(["logs", "restart", "start", "status", "stop"]); + }); + + it("shares the same start command object as top-level nerve start alias", () => { + const subs = daemonCommand.subCommands; + expect(subs?.start).toBe(daemonStartCommand); + }); +}); + +describe("nerve dev", () => { + it("is a foreground dev command", () => { + expect(devCommand.meta?.name).toBe("dev"); + expect(devCommand.meta?.description).toMatch(/foreground/i); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7289a94..8db575f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,9 +1,11 @@ import { defineCommand, runMain } from "citty"; +import { daemonCommand } from "./commands/daemon.js"; +import { devCommand } from "./commands/dev.js"; import { initCommand } from "./commands/init.js"; import { logsCommand } from "./commands/logs.js"; import { senseCommand } from "./commands/sense.js"; -import { startCommand } from "./commands/start.js"; +import { daemonStartCommand } from "./commands/start.js"; import { statusCommand } from "./commands/status.js"; import { stopCommand } from "./commands/stop.js"; import { storeCommand } from "./commands/store.js"; @@ -17,7 +19,9 @@ const main = defineCommand({ }, subCommands: { init: initCommand, - start: startCommand, + daemon: daemonCommand, + dev: devCommand, + start: daemonStartCommand, stop: stopCommand, status: statusCommand, logs: logsCommand, diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts new file mode 100644 index 0000000..7078cbe --- /dev/null +++ b/packages/cli/src/commands/daemon.ts @@ -0,0 +1,31 @@ +import { defineCommand } from "citty"; + +import { logsCommand } from "./logs.js"; +import { daemonStartCommand, runDaemonStartCommand } from "./start.js"; +import { statusCommand } from "./status.js"; +import { runStopCommand, stopCommand } from "./stop.js"; + +const daemonRestartCommand = defineCommand({ + meta: { + name: "restart", + description: "Stop then start the nerve daemon", + }, + async run() { + await runStopCommand(); + await runDaemonStartCommand(); + }, +}); + +export const daemonCommand = defineCommand({ + meta: { + name: "daemon", + description: "Manage the nerve background daemon", + }, + subCommands: { + start: daemonStartCommand, + stop: stopCommand, + status: statusCommand, + restart: daemonRestartCommand, + logs: logsCommand, + }, +}); diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts new file mode 100644 index 0000000..abf1bca --- /dev/null +++ b/packages/cli/src/commands/dev.ts @@ -0,0 +1,17 @@ +import { defineCommand } from "citty"; + +import { runForegroundKernelSession } from "../run-foreground-kernel.js"; +import { loadDaemonModule } from "../workspace-daemon.js"; +import { getNerveRoot } from "../workspace.js"; + +export const devCommand = defineCommand({ + meta: { + name: "dev", + description: "Run the nerve kernel in the foreground (development mode)", + }, + async run() { + const nerveRoot = getNerveRoot(); + const { createKernel } = await loadDaemonModule(nerveRoot); + await runForegroundKernelSession(nerveRoot, createKernel); + }, +}); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 69b910b..ff23b57 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -5,8 +5,6 @@ import { fileURLToPath } from "node:url"; import { defineCommand } from "citty"; -import { runForegroundKernelSession } from "../run-foreground-kernel.js"; -import { loadDaemonModule } from "../workspace-daemon.js"; import { getLogPath, getNerveRoot, @@ -52,15 +50,10 @@ function daemonBootstrapScript(): string { return bootstrapJs; } throw new Error( - `daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using background mode (\`nerve start -d\`).`, + `daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using \`nerve daemon start\`.`, ); } -async function runForeground(nerveRoot: string): Promise { - const { createKernel } = await loadDaemonModule(nerveRoot); - await runForegroundKernelSession(nerveRoot, createKernel); -} - async function runDaemon(nerveRoot: string): Promise { if (isRunning()) { const pid = readPidFile(); @@ -110,29 +103,20 @@ async function runDaemon(nerveRoot: string): Promise { process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`); process.stdout.write(` Logs: ${logPath}\n`); - process.stdout.write(" Run `nerve stop` to stop.\n"); + process.stdout.write(" Run `nerve daemon stop` (or `nerve stop`) to stop.\n"); } -export const startCommand = defineCommand({ +/** Background daemon only — use `nerve dev` for foreground mode. */ +export async function runDaemonStartCommand(): Promise { + await runDaemon(getNerveRoot()); +} + +export const daemonStartCommand = defineCommand({ meta: { name: "start", - description: "Start the nerve daemon", + description: "Start the nerve daemon in the background", }, - args: { - daemon: { - type: "boolean", - alias: "d", - description: "Run as background daemon", - default: false, - }, - }, - async run({ args }) { - const nerveRoot = getNerveRoot(); - - if (args.daemon) { - await runDaemon(nerveRoot); - } else { - await runForeground(nerveRoot); - } + async run() { + await runDaemonStartCommand(); }, }); diff --git a/packages/cli/src/commands/stop.ts b/packages/cli/src/commands/stop.ts index 2e9979f..69529bc 100644 --- a/packages/cli/src/commands/stop.ts +++ b/packages/cli/src/commands/stop.ts @@ -15,44 +15,49 @@ async function waitForExit(pid: number, timeoutMs: number): Promise { return false; } +/** Core stop logic — also used by `nerve daemon restart`. */ +export async function runStopCommand(): Promise { + const pid = readPidFile(); + if (pid === null) { + process.stdout.write("⚠️ No PID file found — daemon may not be running.\n"); + return; + } + + if (!isRunning()) { + process.stdout.write("⚠️ Daemon is not running (stale PID file). Cleaning up.\n"); + removePidFile(); + return; + } + + process.stdout.write(`Stopping nerve daemon (pid ${pid})…\n`); + try { + process.kill(pid, "SIGTERM"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`❌ Failed to send SIGTERM: ${msg}\n`); + process.exit(1); + } + + const graceful = await waitForExit(pid, 10_000); + if (!graceful) { + process.stdout.write("⚠️ Daemon did not exit in 10s — sending SIGKILL.\n"); + try { + process.kill(pid, "SIGKILL"); + } catch { + // already dead + } + } + + removePidFile(); + process.stdout.write("✅ Daemon stopped.\n"); +} + export const stopCommand = defineCommand({ meta: { name: "stop", description: "Stop the nerve daemon", }, async run() { - const pid = readPidFile(); - if (pid === null) { - process.stdout.write("⚠️ No PID file found — daemon may not be running.\n"); - return; - } - - if (!isRunning()) { - process.stdout.write("⚠️ Daemon is not running (stale PID file). Cleaning up.\n"); - removePidFile(); - return; - } - - process.stdout.write(`Stopping nerve daemon (pid ${pid})…\n`); - try { - process.kill(pid, "SIGTERM"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`❌ Failed to send SIGTERM: ${msg}\n`); - process.exit(1); - } - - const graceful = await waitForExit(pid, 10_000); - if (!graceful) { - process.stdout.write("⚠️ Daemon did not exit in 10s — sending SIGKILL.\n"); - try { - process.kill(pid, "SIGKILL"); - } catch { - // already dead - } - } - - removePidFile(); - process.stdout.write("✅ Daemon stopped.\n"); + await runStopCommand(); }, });