diff --git a/packages/cli/package.json b/packages/cli/package.json index 6c93da5..2176d0f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,10 +19,10 @@ }, "dependencies": { "@uncaged/nerve-core": "workspace:*", - "@uncaged/nerve-daemon": "workspace:*", "citty": "^0.1.6" }, "devDependencies": { + "@uncaged/nerve-daemon": "workspace:*", "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.0.0", "vitest": "^4.1.5" diff --git a/packages/cli/src/__tests__/workflow.test.ts b/packages/cli/src/__tests__/workflow.test.ts index 5af3a08..aca698d 100644 --- a/packages/cli/src/__tests__/workflow.test.ts +++ b/packages/cli/src/__tests__/workflow.test.ts @@ -13,7 +13,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createLogStore } from "@uncaged/nerve-daemon"; -import type { LogStore, WorkflowRun } from "@uncaged/nerve-daemon"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -25,6 +24,7 @@ import { statusIcon, } from "../commands/workflow.js"; import { triggerWorkflowViaDaemon } from "../daemon-client.js"; +import type { LogStore, WorkflowRun } from "../daemon-types.js"; // --------------------------------------------------------------------------- // Test helpers diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a3eab6b..9206817 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -26,6 +26,7 @@ const PACKAGE_JSON = `{ "type": "module", "dependencies": { "@uncaged/nerve-core": "latest", + "@uncaged/nerve-daemon": "latest", "drizzle-orm": "latest" }, "devDependencies": { @@ -100,7 +101,7 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise { +async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> { const { execFile } = await import("node:child_process"); const { promisify } = await import("node:util"); const execFileAsync = promisify(execFile); @@ -108,13 +109,13 @@ async function detectPackageManager(): Promise<{ cmd: string; args: string[] }> for (const pm of ["pnpm", "yarn", "npm"]) { try { await execFileAsync(pm, ["--version"]); - const args = pm === "pnpm" ? ["install", "--no-cache"] : ["install"]; - return { cmd: pm, args }; + const installArgs = pm === "pnpm" ? ["install", "--no-cache"] : ["install"]; + return { cmd: pm, installArgs }; } catch { // not available, try next } } - return { cmd: "npm", args: ["install"] }; + return { cmd: "npm", installArgs: ["install"] }; } export const WORKFLOW_NAME_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; @@ -242,8 +243,17 @@ async function runInitWorkspace(force: boolean): Promise { process.stdout.write("Installing dependencies…\n"); try { - const { cmd, args } = await detectPackageManager(); - await runCommand(cmd, args, nerveRoot); + const { cmd, installArgs } = await detectPackageManager(); + await runCommand(cmd, installArgs, nerveRoot); + + process.stdout.write("Rebuilding native module better-sqlite3…\n"); + try { + await runCommand(cmd, ["rebuild", "better-sqlite3"], nerveRoot); + } catch { + process.stdout.write( + "⚠️ rebuild better-sqlite3 failed — if the daemon fails to start, reinstall from the workspace directory.\n", + ); + } } catch { process.stdout.write("⚠️ Install failed — you may need to install dependencies manually.\n"); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 2df8ef5..108a295 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,99 +1,32 @@ -import { createWriteStream, existsSync, readFileSync } from "node:fs"; +import { createWriteStream, existsSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { parseNerveConfig } from "@uncaged/nerve-core"; -import { createKernel } from "@uncaged/nerve-daemon"; import { defineCommand } from "citty"; +import { runForegroundKernelSession } from "../run-foreground-kernel.js"; +import { loadDaemonModule } from "../workspace-daemon.js"; import { getLogPath, getNerveRoot, - getSocketPath, isRunning, readPidFile, + removePidFile, 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); +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); } -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, { - enableFileWatcher: true, - ipcSocketPath: getSocketPath(), - }); - - 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; -} - -/** Path to the CLI entry script (for spawning `start` without `-d`). */ +/** Path to the CLI entry script (used to locate dist/ next to bundled assets). */ function cliEntryScript(): string { const here = fileURLToPath(import.meta.url); const ext = here.endsWith(".ts") ? ".ts" : ".js"; - // When bundled, `here` is already the CLI entry (e.g. dist/cli.js). - // When running from source, `here` is src/commands/start.ts → go up to src/cli.ts. - const candidates = [ - join(dirname(here), `cli${ext}`), // bundled: dist/cli.js - join(dirname(here), "..", `cli${ext}`), // source: src/commands/start.ts → src/cli.ts - ]; + const candidates = [join(dirname(here), `cli${ext}`), join(dirname(here), "..", `cli${ext}`)]; const cliPath = candidates.find((p) => existsSync(p)); if (!cliPath) { throw new Error(`CLI entry not found (searched: ${candidates.join(", ")})`); @@ -101,6 +34,23 @@ function cliEntryScript(): string { return cliPath; } +function daemonBootstrapScript(): string { + const cliPath = cliEntryScript(); + const dir = dirname(cliPath); + const bootstrapJs = join(dir, "daemon-bootstrap.js"); + if (existsSync(bootstrapJs)) { + 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\`).`, + ); +} + +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(); @@ -108,12 +58,6 @@ async function runDaemon(nerveRoot: string): Promise { 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 }); @@ -124,12 +68,13 @@ async function runDaemon(nerveRoot: string): Promise { else resolve(); }); - const cliPath = cliEntryScript(); + const bootstrapPath = daemonBootstrapScript(); - const child = spawn(process.execPath, [cliPath, "start"], { + const child = spawn(process.execPath, [bootstrapPath], { detached: true, stdio: ["ignore", logStream.fd, logStream.fd], - env: { ...process.env, NERVE_DAEMON_MODE: "1" }, + env: { ...process.env, NERVE_ROOT: nerveRoot }, + cwd: nerveRoot, }); child.unref(); @@ -141,6 +86,17 @@ async function runDaemon(nerveRoot: string): Promise { } writePidFile(pid); + + await sleep(1500); + + if (!isRunning()) { + removePidFile(); + process.stderr.write( + `❌ Daemon process exited shortly after start. Check logs at:\n ${logPath}\n`, + ); + process.exit(1); + } + process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`); process.stdout.write(` Logs: ${logPath}\n`); process.stdout.write(" Run `nerve stop` to stop.\n"); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index fa99716..81c8d40 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -1,11 +1,11 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { createLogStore } from "@uncaged/nerve-daemon"; -import type { LogStore, WorkflowRun } from "@uncaged/nerve-daemon"; import { defineCommand } from "citty"; import { triggerWorkflowViaDaemon } from "../daemon-client.js"; +import type { LogStore, WorkflowRun } from "../daemon-types.js"; +import { loadDaemonModule } from "../workspace-daemon.js"; import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js"; export const DEFAULT_PAGE_SIZE = 20; @@ -23,12 +23,14 @@ export function formatTs(ts: number): string { return new Date(ts).toISOString(); } -function openStore(): LogStore { +async function openStore(): Promise { + const nerveRoot = getNerveRoot(); const dbPath = getDbPath(); if (!existsSync(dbPath)) { process.stderr.write("❌ No logs.db found — has the daemon run yet?\n"); process.exit(1); } + const { createLogStore } = await loadDaemonModule(nerveRoot); return createLogStore(dbPath); } @@ -202,7 +204,7 @@ const workflowListCommand = defineCommand({ }, }, async run({ args }) { - const store = openStore(); + const store = await openStore(); try { const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE)); @@ -259,7 +261,7 @@ const workflowInspectCommand = defineCommand({ }, }, async run({ args }) { - const store = openStore(); + const store = await openStore(); try { const limit = Math.max(1, parseIntArg(args.limit, DEFAULT_PAGE_SIZE)); diff --git a/packages/cli/src/daemon-bootstrap.ts b/packages/cli/src/daemon-bootstrap.ts new file mode 100644 index 0000000..86e09af --- /dev/null +++ b/packages/cli/src/daemon-bootstrap.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node + +import { runForegroundKernelSession } from "./run-foreground-kernel.js"; +import { loadDaemonModule } from "./workspace-daemon.js"; + +const nerveRoot = process.env.NERVE_ROOT; +if (nerveRoot === undefined || nerveRoot.length === 0) { + process.stderr.write("[nerve] NERVE_ROOT environment variable is required.\n"); + process.exit(1); +} + +const { createKernel } = await loadDaemonModule(nerveRoot); +await runForegroundKernelSession(nerveRoot, createKernel); diff --git a/packages/cli/src/daemon-types.ts b/packages/cli/src/daemon-types.ts new file mode 100644 index 0000000..96626b2 --- /dev/null +++ b/packages/cli/src/daemon-types.ts @@ -0,0 +1,48 @@ +/** + * Structural types for workflow CLI — mirrors @uncaged/nerve-daemon log-store + * public API so the CLI runtime does not statically depend on the daemon package. + */ + +export type WorkflowRunStatus = + | "queued" + | "started" + | "completed" + | "failed" + | "crashed" + | "dropped" + | "interrupted"; + +export type WorkflowRun = { + runId: string; + workflow: string; + status: WorkflowRunStatus; + ts: number; +}; + +export type LogEntry = { + id?: number; + source: string; + type: string; + refId: string | null; + payload: string | null; + ts: number; +}; + +export type LogQuery = { + source?: string; + type?: string; + refId?: string; + since?: number; + until?: number; + limit?: number; +}; + +/** Subset of daemon LogStore used by the CLI workflow commands. */ +export type LogStore = { + query: (filter?: LogQuery) => LogEntry[]; + getWorkflowRun: (runId: string) => WorkflowRun | null; + getActiveWorkflowRuns: (workflowName?: string) => WorkflowRun[]; + getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[]; + upsertWorkflowRun: (entry: Omit, run: WorkflowRun) => LogEntry; + close: () => void; +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2d92574..40c3a76 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,5 +8,8 @@ export { isRunning, } from "./workspace.js"; -export { createKernel } from "@uncaged/nerve-daemon"; -export type { Kernel } from "@uncaged/nerve-daemon"; +export { + assertWorkspaceDaemonInstalled, + getDaemonEntryPath, + loadDaemonModule, +} from "./workspace-daemon.js"; diff --git a/packages/cli/src/run-foreground-kernel.ts b/packages/cli/src/run-foreground-kernel.ts new file mode 100644 index 0000000..c05a1d7 --- /dev/null +++ b/packages/cli/src/run-foreground-kernel.ts @@ -0,0 +1,88 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { NerveConfig } from "@uncaged/nerve-core"; +import { parseNerveConfig } from "@uncaged/nerve-core"; + +import { getSocketPath } from "./workspace.js"; + +export type CreateKernelFn = ( + config: NerveConfig, + nerveRoot: string, + opts: { enableFileWatcher: boolean; ipcSocketPath: string }, +) => { + groups: Set; + ready: Promise; + stop: () => Promise; +}; + +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); +} + +export async function runForegroundKernelSession( + nerveRoot: string, + createKernel: CreateKernelFn, +): 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, { + enableFileWatcher: true, + ipcSocketPath: getSocketPath(), + }); + + 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; +} diff --git a/packages/cli/src/workspace-daemon.ts b/packages/cli/src/workspace-daemon.ts new file mode 100644 index 0000000..597cc0a --- /dev/null +++ b/packages/cli/src/workspace-daemon.ts @@ -0,0 +1,41 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { NerveConfig } from "@uncaged/nerve-core"; + +import type { LogStore } from "./daemon-types.js"; + +export function getDaemonEntryPath(nerveRoot: string): string { + return join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", "dist", "index.js"); +} + +export function assertWorkspaceDaemonInstalled(nerveRoot: string): void { + const entry = getDaemonEntryPath(nerveRoot); + if (!existsSync(entry)) { + process.stderr.write( + `❌ @uncaged/nerve-daemon is not installed under ${nerveRoot}/node_modules/. Run \`nerve init\` (or \`nerve init --force\`) to install workspace dependencies.\n`, + ); + process.exit(1); + } +} + +/** Loaded from ~/.uncaged-nerve/node_modules at runtime — keep types structural only. */ +export type DaemonModule = { + createKernel: ( + config: NerveConfig, + nerveRoot: string, + options: { enableFileWatcher: boolean; ipcSocketPath: string }, + ) => { + groups: Set; + ready: Promise; + stop: () => Promise; + }; + createLogStore: (dbPath: string) => LogStore; +}; + +export async function loadDaemonModule(nerveRoot: string): Promise { + assertWorkspaceDaemonInstalled(nerveRoot); + const url = pathToFileURL(getDaemonEntryPath(nerveRoot)).href; + return import(url) as Promise; +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 913ec3f..7557912 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -1,11 +1,16 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/cli.ts"], + entry: ["src/index.ts", "src/cli.ts", "src/daemon-bootstrap.ts"], format: ["esm"], dts: true, clean: true, +<<<<<<< Updated upstream banner: { js: "#!/usr/bin/env node", }, +======= + /** Daemon is loaded from ~/.uncaged-nerve/node_modules at runtime — never bundle it. */ + external: ["@uncaged/nerve-daemon"], +>>>>>>> Stashed changes }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5088ce6..c181826 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@uncaged/nerve-core': specifier: workspace:* version: link:../core - '@uncaged/nerve-daemon': - specifier: workspace:* - version: link:../daemon citty: specifier: ^0.1.6 version: 0.1.6 @@ -36,6 +33,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.17 + '@uncaged/nerve-daemon': + specifier: workspace:* + version: link:../daemon vitest: specifier: ^4.1.5 version: 4.1.5(@types/node@22.19.17)(vite@8.0.9(@types/node@22.19.17)(esbuild@0.27.7)(yaml@2.8.3))