From ad2b40dd4f01c18fc5e43afed8804185321dcbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 10:16:41 +0000 Subject: [PATCH 1/2] feat: implement Phase 5 CLI & User Workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure CLI with citty multi-command framework - nerve init: create workspace skeleton at ~/.uncaged-nerve/ - nerve start: foreground + daemon (-d) modes with graceful shutdown - nerve stop: SIGTERM → 10s wait → SIGKILL, PID file cleanup - nerve status: show pid, uptime, senses, workers - nerve validate: parse nerve.yaml with error reporting - workspace.ts: shared utilities (PID file, paths, isRunning) - Example cpu-usage sense with realistic os.cpus() compute 小橘 🍊(NEKO Team) --- packages/cli/package.json | 6 +- packages/cli/src/cli.ts | 119 ++++---------------- packages/cli/src/commands/init.ts | 153 ++++++++++++++++++++++++++ packages/cli/src/commands/start.ts | 138 +++++++++++++++++++++++ packages/cli/src/commands/status.ts | 65 +++++++++++ packages/cli/src/commands/stop.ts | 58 ++++++++++ packages/cli/src/commands/validate.ts | 41 +++++++ packages/cli/src/index.ts | 11 +- packages/cli/src/workspace.ts | 45 ++++++++ packages/cli/tsconfig.json | 4 +- packages/core/tsconfig.json | 3 +- packages/daemon/src/kernel.ts | 6 +- packages/daemon/tsconfig.json | 3 +- pnpm-lock.yaml | 32 +++++- 14 files changed, 574 insertions(+), 110 deletions(-) create mode 100644 packages/cli/src/commands/init.ts create mode 100644 packages/cli/src/commands/start.ts create mode 100644 packages/cli/src/commands/status.ts create mode 100644 packages/cli/src/commands/stop.ts create mode 100644 packages/cli/src/commands/validate.ts create mode 100644 packages/cli/src/workspace.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 7269994..5790655 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,10 @@ }, "dependencies": { "@uncaged/nerve-core": "workspace:*", - "@uncaged/nerve-daemon": "workspace:*" + "@uncaged/nerve-daemon": "workspace:*", + "citty": "^0.1.6" + }, + "devDependencies": { + "@types/node": "^22.0.0" } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6457455..31fb1fd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,104 +1,25 @@ #!/usr/bin/env node -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { defineCommand, runMain } from "citty"; -import { parseNerveConfig } from "@uncaged/nerve-core"; -import { createKernel } from "@uncaged/nerve-daemon"; +import { initCommand } from "./commands/init.js"; +import { startCommand } from "./commands/start.js"; +import { statusCommand } from "./commands/status.js"; +import { stopCommand } from "./commands/stop.js"; +import { validateCommand } from "./commands/validate.js"; -const DEFAULT_NERVE_ROOT = join(homedir(), ".uncaged-nerve"); - -function parseArgs(argv: string[]): { root: string } { - // Skip argv[0] (node), argv[1] (script), argv[2] ('start' subcommand) - const args = argv.slice(3); - let root = DEFAULT_NERVE_ROOT; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--root" && i + 1 < args.length) { - root = args[i + 1]; - i++; - } else if (!args[i].startsWith("-")) { - process.stderr.write("Usage: nerve start [--root ]\n"); - process.exit(1); - } - } - - return { root }; -} - -function readConfig(nerveRoot: string): ReturnType { - const configPath = join(nerveRoot, "nerve.yaml"); - let raw: string; - try { - raw = readFileSync(configPath, "utf8"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - return { ok: false, error: new Error(`Cannot read ${configPath}: ${msg}`) }; - } - return parseNerveConfig(raw); -} - -async function main(): Promise { - const subcommand = process.argv[2]; - - if (subcommand !== "start") { - process.stderr.write("Usage: nerve start [--root ]\n"); - process.exit(1); - } - - const { root } = parseArgs(process.argv); - - const configResult = readConfig(root); - if (!configResult.ok) { - process.stderr.write(`[nerve] Config error: ${configResult.error.message}\n`); - process.exit(1); - } - - const config = configResult.value; - - const kernel = createKernel(config, root); - - process.stderr.write( - `[nerve] Starting — ${kernel.groups.size} group(s), ${kernel.senseCount} sense(s)\n`, - ); - - for (const group of kernel.groups) { - const groupSenses = Object.entries(config.senses) - .filter(([, sc]) => sc.group === group) - .map(([name]) => name); - process.stderr.write(`[nerve] group "${group}": ${groupSenses.join(", ")}\n`); - } - - let shuttingDown = false; - - async function shutdown(): Promise { - if (shuttingDown) return; - shuttingDown = true; - process.stderr.write("\n[nerve] Shutting down…\n"); - await kernel.stop(); - process.exit(0); - } - - process.on("SIGINT", () => { - shutdown().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`[nerve] Shutdown error: ${msg}\n`); - process.exit(1); - }); - }); - - process.on("SIGTERM", () => { - shutdown().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`[nerve] Shutdown error: ${msg}\n`); - process.exit(1); - }); - }); -} - -main().catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`[nerve] Fatal error: ${msg}\n`); - process.exit(1); +const main = defineCommand({ + meta: { + name: "nerve", + description: "Nerve — an AI agent kernel", + }, + subCommands: { + init: initCommand, + start: startCommand, + stop: stopCommand, + status: statusCommand, + validate: validateCommand, + }, }); + +runMain(main); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..ebca351 --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,153 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { defineCommand } from "citty"; + +import { getNerveRoot } from "../workspace.js"; + +const NERVE_YAML = `# nerve.yaml — Nerve workspace configuration +senses: + cpu-usage: + group: system + throttle: 5s + timeout: 10s + grace_period: null + +reflexes: + - kind: sense + sense: cpu-usage + interval: 10s +`; + +const PACKAGE_JSON = `{ + "name": "my-nerve-workspace", + "version": "0.0.1", + "private": true, + "type": "module", + "dependencies": { + "@uncaged/nerve-core": "latest", + "drizzle-orm": "latest" + }, + "devDependencies": { + "drizzle-kit": "latest" + } +} +`; + +const GITIGNORE = `data/ +node_modules/ +`; + +const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const cpuUsage = sqliteTable("cpu_usage", { + id: integer("id").primaryKey({ autoIncrement: true }), + ts: integer("ts").notNull(), + model: text("model").notNull(), + loadPercent: real("load_percent").notNull(), +}); +`; + +const CPU_INDEX_TS = `import { cpus } from "node:os"; + +export async function compute(): Promise { + const cpuList = cpus(); + + let totalIdle = 0; + let totalTick = 0; + for (const cpu of cpuList) { + for (const [, time] of Object.entries(cpu.times)) { + totalTick += time; + } + totalIdle += cpu.times.idle; + } + + const loadPercent = totalTick === 0 ? 0 : ((totalTick - totalIdle) / totalTick) * 100; + + return { + model: cpuList[0]?.model ?? "unknown", + loadPercent: Math.round(loadPercent * 100) / 100, + ts: Date.now(), + }; +} +`; + +const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + model TEXT NOT NULL, + load_percent REAL NOT NULL +); +`; + +function writeFile(filePath: string, content: string): void { + const dir = join(filePath, ".."); + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath, content, "utf8"); +} + +async function runCommand(cmd: string, args: string[], cwd: string): Promise { + const { spawn } = await import("node:child_process"); + await new Promise((resolve, reject) => { + const child = spawn(cmd, args, { cwd, stdio: "inherit" }); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + child.on("error", reject); + }); +} + +export const initCommand = defineCommand({ + meta: { + name: "init", + description: "Initialize the ~/.uncaged-nerve/ workspace", + }, + args: { + force: { + type: "boolean", + description: "Reinitialize even if workspace already exists (preserves data/)", + default: false, + }, + }, + async run({ args }) { + const nerveRoot = getNerveRoot(); + + if (existsSync(nerveRoot) && !args.force) { + process.stderr.write("⚠️ ~/.uncaged-nerve/ already exists. Use --force to reinitialize.\n"); + process.exit(1); + } + + mkdirSync(join(nerveRoot, "data"), { recursive: true }); + mkdirSync(join(nerveRoot, "senses", "cpu-usage", "migrations"), { recursive: true }); + + writeFile(join(nerveRoot, "nerve.yaml"), NERVE_YAML); + writeFile(join(nerveRoot, "package.json"), PACKAGE_JSON); + writeFile(join(nerveRoot, ".gitignore"), GITIGNORE); + writeFile(join(nerveRoot, "senses", "cpu-usage", "schema.ts"), CPU_SCHEMA_TS); + writeFile(join(nerveRoot, "senses", "cpu-usage", "index.ts"), CPU_INDEX_TS); + writeFile( + join(nerveRoot, "senses", "cpu-usage", "migrations", "0001_init.sql"), + CPU_MIGRATION_SQL, + ); + + process.stdout.write("Installing dependencies…\n"); + try { + await runCommand("pnpm", ["install", "--no-cache"], nerveRoot); + } catch { + process.stdout.write("⚠️ pnpm install failed — you may need to install manually.\n"); + } + + if (!existsSync(join(nerveRoot, ".git"))) { + try { + await runCommand("git", ["init"], nerveRoot); + } catch { + process.stdout.write("⚠️ git init failed — skipping.\n"); + } + } + + process.stdout.write( + "✅ Workspace created at ~/.uncaged-nerve/\n 1 example sense: cpu-usage\n Run `nerve start` to launch the daemon.\n", + ); + }, +}); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts new file mode 100644 index 0000000..522b885 --- /dev/null +++ b/packages/cli/src/commands/start.ts @@ -0,0 +1,138 @@ +import { createWriteStream } from "node:fs"; +import { readFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { parseNerveConfig } from "@uncaged/nerve-core"; +import { createKernel } from "@uncaged/nerve-daemon"; +import { defineCommand } from "citty"; + +import { getLogPath, getNerveRoot, isRunning, readPidFile, writePidFile } from "../workspace.js"; + +function readConfig(nerveRoot: string): ReturnType { + const configPath = join(nerveRoot, "nerve.yaml"); + let raw: string; + try { + raw = readFileSync(configPath, "utf8"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: new Error(`❌ Cannot read ${configPath}: ${msg}`) }; + } + return parseNerveConfig(raw); +} + +async function runForeground(nerveRoot: string): Promise { + const configResult = readConfig(nerveRoot); + if (!configResult.ok) { + process.stderr.write(`${configResult.error.message}\n`); + process.exit(1); + } + + const config = configResult.value; + const kernel = createKernel(config, nerveRoot); + + const senseNames = Object.keys(config.senses); + const groups = [...kernel.groups]; + + process.stdout.write( + `✅ Nerve starting — ${senseNames.length} sense(s), ${groups.length} group(s)\n`, + ); + for (const group of groups) { + const groupSenses = Object.entries(config.senses) + .filter(([, sc]) => sc.group === group) + .map(([name]) => name); + process.stdout.write(` group "${group}": ${groupSenses.join(", ")}\n`); + } + process.stdout.write(" Press Ctrl+C to stop.\n"); + + let shuttingDown = false; + + async function shutdown(): Promise { + if (shuttingDown) return; + shuttingDown = true; + process.stdout.write("\n[nerve] Shutting down…\n"); + await kernel.stop(); + process.exit(0); + } + + process.on("SIGINT", () => { + shutdown().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`[nerve] Shutdown error: ${msg}\n`); + process.exit(1); + }); + }); + + process.on("SIGTERM", () => { + shutdown().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`[nerve] Shutdown error: ${msg}\n`); + process.exit(1); + }); + }); + + await kernel.ready; +} + +async function runDaemon(nerveRoot: string): Promise { + if (isRunning()) { + const pid = readPidFile(); + process.stderr.write(`⚠️ Nerve daemon is already running (pid ${pid}).\n`); + process.exit(1); + } + + const configResult = readConfig(nerveRoot); + if (!configResult.ok) { + process.stderr.write(`${configResult.error.message}\n`); + process.exit(1); + } + + const logPath = getLogPath(); + await mkdir(join(nerveRoot, "logs"), { recursive: true }); + + const { spawn } = await import("node:child_process"); + const logStream = createWriteStream(logPath, { flags: "a" }); + + const child = spawn(process.execPath, [process.argv[1], "start"], { + detached: true, + stdio: ["ignore", logStream as unknown as "pipe", logStream as unknown as "pipe"], + env: { ...process.env, NERVE_DAEMON_MODE: "1" }, + }); + + child.unref(); + + const pid = child.pid; + if (!pid) { + process.stderr.write("❌ Failed to spawn daemon process.\n"); + process.exit(1); + } + + writePidFile(pid); + process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`); + process.stdout.write(` Logs: ${logPath}\n`); + process.stdout.write(" Run `nerve stop` to stop.\n"); +} + +export const startCommand = defineCommand({ + meta: { + name: "start", + description: "Start the nerve daemon", + }, + 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); + } + }, +}); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts new file mode 100644 index 0000000..8a2a513 --- /dev/null +++ b/packages/cli/src/commands/status.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { parseNerveConfig } from "@uncaged/nerve-core"; +import { defineCommand } from "citty"; + +import { getNerveRoot, isRunning, readPidFile } from "../workspace.js"; + +function formatUptime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +export const statusCommand = defineCommand({ + meta: { + name: "status", + description: "Show nerve daemon status", + }, + async run() { + if (!isRunning()) { + process.stdout.write("😴 Nerve daemon is not running.\n"); + return; + } + + const pid = readPidFile() as number; + + const configPath = join(getNerveRoot(), "nerve.yaml"); + let senseList: string[] = []; + let workerGroups: string[] = []; + + try { + const raw = readFileSync(configPath, "utf8"); + const result = parseNerveConfig(raw); + if (result.ok) { + senseList = Object.keys(result.value.senses); + workerGroups = [...new Set(Object.values(result.value.senses).map((s) => s.group))]; + } + } catch { + // config may not be readable; continue with what we have + } + + const pidStat = readFileSync(`/proc/${pid}/stat`, "utf8").split(" "); + const startJiffies = Number(pidStat[21]); + const clkTck = 100; + const uptimeRaw = readFileSync("/proc/uptime", "utf8").split(" ")[0]; + const systemUptimeSec = Number.parseFloat(uptimeRaw); + const procesStartSec = startJiffies / clkTck; + const uptimeSec = systemUptimeSec - procesStartSec; + const uptimeMs = uptimeSec * 1000; + + process.stdout.write("✅ Nerve daemon is running.\n"); + process.stdout.write(` pid: ${pid}\n`); + process.stdout.write(` uptime: ${formatUptime(uptimeMs)}\n`); + process.stdout.write(` senses: ${senseList.length > 0 ? senseList.join(", ") : "(none)"}\n`); + process.stdout.write( + ` workers: ${workerGroups.length > 0 ? workerGroups.join(", ") : "(none)"}\n`, + ); + process.stdout.write(" signals: (pending SignalBus persistence)\n"); + }, +}); diff --git a/packages/cli/src/commands/stop.ts b/packages/cli/src/commands/stop.ts new file mode 100644 index 0000000..2e9979f --- /dev/null +++ b/packages/cli/src/commands/stop.ts @@ -0,0 +1,58 @@ +import { defineCommand } from "citty"; + +import { isRunning, readPidFile, removePidFile } from "../workspace.js"; + +async function waitForExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + process.kill(pid, 0); + } catch { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + return false; +} + +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"); + }, +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts new file mode 100644 index 0000000..cc53f84 --- /dev/null +++ b/packages/cli/src/commands/validate.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { parseNerveConfig } from "@uncaged/nerve-core"; +import { defineCommand } from "citty"; + +import { getNerveRoot } from "../workspace.js"; + +export const validateCommand = defineCommand({ + meta: { + name: "validate", + description: "Validate nerve.yaml configuration", + }, + async run() { + const configPath = join(getNerveRoot(), "nerve.yaml"); + let raw: string; + try { + raw = readFileSync(configPath, "utf8"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`❌ Cannot read ${configPath}: ${msg}\n`); + process.exit(1); + } + + const result = parseNerveConfig(raw); + if (!result.ok) { + process.stderr.write("❌ Config validation failed:\n"); + process.stderr.write(` 1. ${result.error.message}\n`); + process.exit(1); + } + + const config = result.value; + const senseCount = Object.keys(config.senses).length; + const reflexCount = config.reflexes.length; + const workflowCount = config.workflows ? Object.keys(config.workflows).length : 0; + + process.stdout.write( + `✅ nerve.yaml is valid — ${senseCount} sense(s), ${reflexCount} reflex(es), ${workflowCount} workflow(s)\n`, + ); + }, +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d41c4b7..f5dc52f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,2 +1,9 @@ -export { createKernel } from "@uncaged/nerve-daemon"; -export type { Kernel } from "@uncaged/nerve-daemon"; +export { + getNerveRoot, + getPidPath, + getLogPath, + readPidFile, + writePidFile, + removePidFile, + isRunning, +} from "./workspace.js"; diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts new file mode 100644 index 0000000..c8c7a2a --- /dev/null +++ b/packages/cli/src/workspace.ts @@ -0,0 +1,45 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function getNerveRoot(): string { + return join(homedir(), ".uncaged-nerve"); +} + +export function getPidPath(): string { + return join(getNerveRoot(), "nerve.pid"); +} + +export function getLogPath(): string { + return join(getNerveRoot(), "logs", "nerve.log"); +} + +export function readPidFile(): number | null { + const pidPath = getPidPath(); + if (!existsSync(pidPath)) return null; + const raw = readFileSync(pidPath, "utf8").trim(); + const pid = Number.parseInt(raw, 10); + return Number.isNaN(pid) ? null : pid; +} + +export function writePidFile(pid: number): void { + writeFileSync(getPidPath(), String(pid), "utf8"); +} + +export function removePidFile(): void { + const pidPath = getPidPath(); + if (existsSync(pidPath)) { + rmSync(pidPath); + } +} + +export function isRunning(): boolean { + const pid = readPidFile(); + if (pid === null) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index a086b14..c449f63 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": false, + "types": ["node"] }, "include": ["src"] } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index a086b14..9036088 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": false }, "include": ["src"] } diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index e1f0356..4fbbdde 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -115,7 +115,7 @@ export function createKernel( // eslint-disable-next-line prefer-const let scheduler: ReflexScheduler = null as unknown as ReflexScheduler; - let readyResolve: () => void; + let readyResolve: (() => void) | undefined; const ready = new Promise((resolve) => { readyResolve = resolve; }); @@ -138,7 +138,7 @@ export function createKernel( if (msg.type === "ready") { pendingReadyCount -= 1; if (pendingReadyCount <= 0) { - readyResolve(); + readyResolve?.(); } return; } @@ -205,7 +205,7 @@ export function createKernel( scheduler = createReflexScheduler(config, bus, triggerFn); if (groups.size === 0) { - readyResolve(); + readyResolve?.(); } for (const group of groups) { diff --git a/packages/daemon/tsconfig.json b/packages/daemon/tsconfig.json index a086b14..9036088 100644 --- a/packages/daemon/tsconfig.json +++ b/packages/daemon/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "composite": false }, "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10d830f..eb21479 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,13 @@ importers: '@uncaged/nerve-daemon': specifier: workspace:* version: link:../daemon + citty: + specifier: ^0.1.6 + version: 0.1.6 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 packages/core: dependencies: @@ -559,6 +566,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@22.19.17': + resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -639,6 +649,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1142,6 +1155,9 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} @@ -1534,7 +1550,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.6.0 + '@types/node': 22.19.17 '@types/chai@5.2.3': dependencies: @@ -1545,9 +1561,14 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@22.19.17': + dependencies: + undici-types: 6.21.0 + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 + optional: true '@vitest/expect@4.1.5': dependencies: @@ -1633,6 +1654,10 @@ snapshots: chownr@1.1.4: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + commander@4.1.1: {} confbox@0.1.8: {} @@ -2057,7 +2082,10 @@ snapshots: ufo@1.6.3: {} - undici-types@7.19.2: {} + undici-types@6.21.0: {} + + undici-types@7.19.2: + optional: true util-deprecate@1.0.2: {} -- 2.43.0 From 097a4790be816fb673acb4aaabb467c6b73454af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 22 Apr 2026 10:32:06 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20#12=20review=20?= =?UTF-8?q?=E2=80=94=20cross-platform,=20pkg=20manager=20detection,=20expo?= =?UTF-8?q?rts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - status.ts: wrap /proc in try/catch, fallback to PID file mtime (macOS safe) - init.ts: detect pnpm > yarn > npm instead of hardcoding pnpm - init.ts: use dirname() instead of join(.., '..') - index.ts: restore createKernel/Kernel re-exports (non-breaking) - start.ts: use fileURLToPath(import.meta.url) for daemon spawn - start.ts: use logStream.fd instead of double type cast - validate.ts: remove misleading error numbering 小橘 🍊(NEKO Team) --- packages/cli/src/commands/init.ts | 27 ++++++++++++++++---- packages/cli/src/commands/start.ts | 11 ++++++-- packages/cli/src/commands/status.ts | 36 +++++++++++++++++++-------- packages/cli/src/commands/validate.ts | 3 +-- packages/cli/src/index.ts | 3 +++ 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ebca351..0603d45 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { defineCommand } from "citty"; @@ -81,8 +81,7 @@ const CPU_MIGRATION_SQL = `CREATE TABLE IF NOT EXISTS cpu_usage ( `; function writeFile(filePath: string, content: string): void { - const dir = join(filePath, ".."); - mkdirSync(dir, { recursive: true }); + mkdirSync(dirname(filePath), { recursive: true }); writeFileSync(filePath, content, "utf8"); } @@ -98,6 +97,23 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execFileAsync = promisify(execFile); + + for (const pm of ["pnpm", "yarn", "npm"]) { + try { + await execFileAsync(pm, ["--version"]); + const args = pm === "pnpm" ? ["install", "--no-cache"] : ["install"]; + return { cmd: pm, args }; + } catch { + // not available, try next + } + } + return { cmd: "npm", args: ["install"] }; +} + export const initCommand = defineCommand({ meta: { name: "init", @@ -133,9 +149,10 @@ export const initCommand = defineCommand({ process.stdout.write("Installing dependencies…\n"); try { - await runCommand("pnpm", ["install", "--no-cache"], nerveRoot); + const { cmd, args } = await detectPackageManager(); + await runCommand(cmd, args, nerveRoot); } catch { - process.stdout.write("⚠️ pnpm install failed — you may need to install manually.\n"); + process.stdout.write("⚠️ Install failed — you may need to install dependencies manually.\n"); } if (!existsSync(join(nerveRoot, ".git"))) { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 522b885..dc46a4e 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -2,6 +2,7 @@ import { createWriteStream } from "node:fs"; import { readFileSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseNerveConfig } from "@uncaged/nerve-core"; import { createKernel } from "@uncaged/nerve-daemon"; @@ -92,10 +93,16 @@ async function runDaemon(nerveRoot: string): Promise { const { spawn } = await import("node:child_process"); const logStream = createWriteStream(logPath, { flags: "a" }); + await new Promise((resolve) => { + if (logStream.pending) logStream.once("open", () => resolve()); + else resolve(); + }); - const child = spawn(process.execPath, [process.argv[1], "start"], { + const selfPath = fileURLToPath(import.meta.url); + + const child = spawn(process.execPath, [selfPath, "start"], { detached: true, - stdio: ["ignore", logStream as unknown as "pipe", logStream as unknown as "pipe"], + stdio: ["ignore", logStream.fd, logStream.fd], env: { ...process.env, NERVE_DAEMON_MODE: "1" }, }); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 8a2a513..e7d2954 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -1,10 +1,10 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { parseNerveConfig } from "@uncaged/nerve-core"; import { defineCommand } from "citty"; -import { getNerveRoot, isRunning, readPidFile } from "../workspace.js"; +import { getNerveRoot, getPidPath, isRunning, readPidFile } from "../workspace.js"; function formatUptime(ms: number): string { const totalSeconds = Math.floor(ms / 1000); @@ -16,6 +16,26 @@ function formatUptime(ms: number): string { return `${seconds}s`; } +function getUptimeMs(pid: number): number | null { + try { + const pidStat = readFileSync(`/proc/${pid}/stat`, "utf8").split(" "); + const startJiffies = Number(pidStat[21]); + const clkTck = 100; + const uptimeRaw = readFileSync("/proc/uptime", "utf8").split(" ")[0]; + const systemUptimeSec = Number.parseFloat(uptimeRaw); + const processStartSec = startJiffies / clkTck; + return (systemUptimeSec - processStartSec) * 1000; + } catch { + // /proc not available (non-Linux); fall back to PID file mtime + try { + const pidMtime = statSync(getPidPath()).mtimeMs; + return Date.now() - pidMtime; + } catch { + return null; + } + } +} + export const statusCommand = defineCommand({ meta: { name: "status", @@ -44,18 +64,12 @@ export const statusCommand = defineCommand({ // config may not be readable; continue with what we have } - const pidStat = readFileSync(`/proc/${pid}/stat`, "utf8").split(" "); - const startJiffies = Number(pidStat[21]); - const clkTck = 100; - const uptimeRaw = readFileSync("/proc/uptime", "utf8").split(" ")[0]; - const systemUptimeSec = Number.parseFloat(uptimeRaw); - const procesStartSec = startJiffies / clkTck; - const uptimeSec = systemUptimeSec - procesStartSec; - const uptimeMs = uptimeSec * 1000; + const uptimeMs = getUptimeMs(pid); + const uptimeStr = uptimeMs !== null ? formatUptime(uptimeMs) : "unknown"; process.stdout.write("✅ Nerve daemon is running.\n"); process.stdout.write(` pid: ${pid}\n`); - process.stdout.write(` uptime: ${formatUptime(uptimeMs)}\n`); + process.stdout.write(` uptime: ${uptimeStr}\n`); process.stdout.write(` senses: ${senseList.length > 0 ? senseList.join(", ") : "(none)"}\n`); process.stdout.write( ` workers: ${workerGroups.length > 0 ? workerGroups.join(", ") : "(none)"}\n`, diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index cc53f84..1a8e860 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -24,8 +24,7 @@ export const validateCommand = defineCommand({ const result = parseNerveConfig(raw); if (!result.ok) { - process.stderr.write("❌ Config validation failed:\n"); - process.stderr.write(` 1. ${result.error.message}\n`); + process.stderr.write(`❌ Config validation failed: ${result.error.message}\n`); process.exit(1); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f5dc52f..2d92574 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,3 +7,6 @@ export { removePidFile, isRunning, } from "./workspace.js"; + +export { createKernel } from "@uncaged/nerve-daemon"; +export type { Kernel } from "@uncaged/nerve-daemon"; -- 2.43.0