feat: implement Phase 5 CLI & User Workspace
- 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)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-99
@@ -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 <path>]\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return { root };
|
||||
}
|
||||
|
||||
function readConfig(nerveRoot: string): ReturnType<typeof parseNerveConfig> {
|
||||
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<void> {
|
||||
const subcommand = process.argv[2];
|
||||
|
||||
if (subcommand !== "start") {
|
||||
process.stderr.write("Usage: nerve start [--root <path>]\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<void> {
|
||||
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);
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<void> {
|
||||
const { spawn } = await import("node:child_process");
|
||||
await new Promise<void>((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",
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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<typeof parseNerveConfig> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineCommand } from "citty";
|
||||
|
||||
import { isRunning, readPidFile, removePidFile } from "../workspace.js";
|
||||
|
||||
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
await new Promise<void>((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");
|
||||
},
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"composite": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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<void>((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) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": "src",
|
||||
"composite": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Generated
+30
-2
@@ -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: {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user