From c8e64098371ef108db32b9af2dcd3c0d1f2f5c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 23 Apr 2026 06:58:00 +0800 Subject: [PATCH 1/2] refactor(cli): decouple daemon native deps from CLI global install - Move @uncaged/nerve-daemon from runtime to devDependencies - Dynamic import daemon from workspace node_modules at runtime - Add daemon-bootstrap.ts as separate entry for background daemon spawn - Extract run-foreground-kernel.ts and workspace-daemon.ts modules - Add daemon-types.ts for structural types (no runtime daemon import) - Rebuild better-sqlite3 in workspace during nerve init - Validate daemon process liveness after spawn in background mode - Mark @uncaged/nerve-daemon as external in tsup config Closes #41 --- packages/cli/package.json | 2 +- packages/cli/src/__tests__/workflow.test.ts | 2 +- packages/cli/src/commands/init.ts | 22 +++- packages/cli/src/commands/start.ts | 128 +++++++------------- packages/cli/src/commands/workflow.ts | 12 +- packages/cli/src/daemon-bootstrap.ts | 13 ++ packages/cli/src/daemon-types.ts | 48 ++++++++ packages/cli/src/index.ts | 7 +- packages/cli/src/run-foreground-kernel.ts | 88 ++++++++++++++ packages/cli/src/workspace-daemon.ts | 41 +++++++ packages/cli/tsup.config.ts | 7 +- pnpm-lock.yaml | 6 +- 12 files changed, 271 insertions(+), 105 deletions(-) create mode 100644 packages/cli/src/daemon-bootstrap.ts create mode 100644 packages/cli/src/daemon-types.ts create mode 100644 packages/cli/src/run-foreground-kernel.ts create mode 100644 packages/cli/src/workspace-daemon.ts 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)) -- 2.43.0 From 282a802f06c6f424009fdba61850d026746261cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Thu, 23 Apr 2026 07:07:38 +0800 Subject: [PATCH 2/2] fix: address review feedback on PR #42 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [BLOCKER] tsup.config.ts: resolve merge conflict — keep both banner (shebang) and external (daemon decoupling) 2. [SHOULD-FIX] assertWorkspaceDaemonInstalled: throw Error instead of process.exit(1) — callers decide error handling 3. [SHOULD-FIX] getDaemonEntryPath: read daemon's package.json 'main' field instead of hardcoding dist/index.js 4. [SHOULD-FIX] daemon startup check: replace sleep(1500) with IPC socket polling (200ms intervals, 5s timeout) 5. [SHOULD-FIX] daemon-types drift: add vitest type-level assertions that verify CLI mirror types stay assignable with daemon exports --- .../cli/src/__tests__/daemon-types.test.ts | 47 +++++++++++++++++++ packages/cli/src/commands/start.ts | 19 ++++++-- packages/cli/src/daemon-types.ts | 3 ++ packages/cli/src/workspace-daemon.ts | 27 +++++++---- packages/cli/tsup.config.ts | 5 +- 5 files changed, 84 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/__tests__/daemon-types.test.ts diff --git a/packages/cli/src/__tests__/daemon-types.test.ts b/packages/cli/src/__tests__/daemon-types.test.ts new file mode 100644 index 0000000..79594a8 --- /dev/null +++ b/packages/cli/src/__tests__/daemon-types.test.ts @@ -0,0 +1,47 @@ +/** + * Compile-time check: daemon-types.ts stays in sync with @uncaged/nerve-daemon exports. + * If the daemon package changes its public API, this file will fail to compile. + */ + +import type { + LogEntry as DaemonLogEntry, + LogQuery as DaemonLogQuery, + LogStore as DaemonLogStore, + WorkflowRun as DaemonWorkflowRun, + WorkflowRunStatus as DaemonWorkflowRunStatus, +} from "@uncaged/nerve-daemon"; +import { describe, it, expectTypeOf } from "vitest"; + +import type { + LogEntry, + LogQuery, + LogStore, + WorkflowRun, + WorkflowRunStatus, +} from "../daemon-types.js"; + +describe("daemon-types drift guard", () => { + it("WorkflowRunStatus is assignable both ways", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("WorkflowRun is assignable both ways", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("LogEntry is assignable both ways", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("LogQuery is assignable both ways", () => { + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + + it("LogStore has all required methods", () => { + expectTypeOf().toMatchTypeOf>(); + }); +}); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 108a295..69b910b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -16,9 +16,19 @@ import { writePidFile, } from "../workspace.js"; -function sleep(ms: number): Promise { +function waitForSocket(socketPath: string, timeoutMs = 5000, intervalMs = 200): Promise { return new Promise((resolve) => { - setTimeout(resolve, ms); + const deadline = Date.now() + timeoutMs; + const check = (): void => { + if (existsSync(socketPath)) { + resolve(true); + } else if (Date.now() >= deadline) { + resolve(false); + } else { + setTimeout(check, intervalMs); + } + }; + check(); }); } @@ -87,9 +97,10 @@ async function runDaemon(nerveRoot: string): Promise { writePidFile(pid); - await sleep(1500); + const { getSocketPath } = await import("../workspace.js"); + const ready = await waitForSocket(getSocketPath(), 5000); - if (!isRunning()) { + if (!ready || !isRunning()) { removePidFile(); process.stderr.write( `❌ Daemon process exited shortly after start. Check logs at:\n ${logPath}\n`, diff --git a/packages/cli/src/daemon-types.ts b/packages/cli/src/daemon-types.ts index 96626b2..351c3a8 100644 --- a/packages/cli/src/daemon-types.ts +++ b/packages/cli/src/daemon-types.ts @@ -1,6 +1,9 @@ /** * 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. + * + * ⚠️ Keep in sync with @uncaged/nerve-daemon exports. + * Run `pnpm --filter @uncaged/nerve-cli test` to catch drift via satisfies assertions. */ export type WorkflowRunStatus = diff --git a/packages/cli/src/workspace-daemon.ts b/packages/cli/src/workspace-daemon.ts index 597cc0a..2abcd17 100644 --- a/packages/cli/src/workspace-daemon.ts +++ b/packages/cli/src/workspace-daemon.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -6,18 +7,26 @@ 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 getDaemonEntryPath(nerveRoot: string): string | undefined { + const pkgPath = join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", "package.json"); + if (!existsSync(pkgPath)) return undefined; + try { + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { main?: string }; + const main = pkg.main ?? "dist/index.js"; + return join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", main); + } catch { + return join(nerveRoot, "node_modules", "@uncaged", "nerve-daemon", "dist", "index.js"); + } } -export function assertWorkspaceDaemonInstalled(nerveRoot: string): void { +export function assertWorkspaceDaemonInstalled(nerveRoot: string): string { 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`, + if (!entry || !existsSync(entry)) { + throw new Error( + `@uncaged/nerve-daemon is not installed under ${nerveRoot}/node_modules/. Run \`nerve init\` (or \`nerve init --force\`) to install workspace dependencies.`, ); - process.exit(1); } + return entry; } /** Loaded from ~/.uncaged-nerve/node_modules at runtime — keep types structural only. */ @@ -35,7 +44,7 @@ export type DaemonModule = { }; export async function loadDaemonModule(nerveRoot: string): Promise { - assertWorkspaceDaemonInstalled(nerveRoot); - const url = pathToFileURL(getDaemonEntryPath(nerveRoot)).href; + const entry = assertWorkspaceDaemonInstalled(nerveRoot); + const url = pathToFileURL(entry).href; return import(url) as Promise; } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 7557912..402a489 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -5,12 +5,9 @@ export default defineConfig({ 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. */ + /** Daemon is loaded from workspace node_modules at runtime — never bundle it. */ external: ["@uncaged/nerve-daemon"], ->>>>>>> Stashed changes }); -- 2.43.0