refactor(cli): decouple daemon native deps from CLI global install — closes #41 #42
@@ -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"
|
||||
|
||||
@@ -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<WorkflowRunStatus>().toMatchTypeOf<DaemonWorkflowRunStatus>();
|
||||
expectTypeOf<DaemonWorkflowRunStatus>().toMatchTypeOf<WorkflowRunStatus>();
|
||||
});
|
||||
|
||||
it("WorkflowRun is assignable both ways", () => {
|
||||
expectTypeOf<WorkflowRun>().toMatchTypeOf<DaemonWorkflowRun>();
|
||||
expectTypeOf<DaemonWorkflowRun>().toMatchTypeOf<WorkflowRun>();
|
||||
});
|
||||
|
||||
it("LogEntry is assignable both ways", () => {
|
||||
expectTypeOf<LogEntry>().toMatchTypeOf<DaemonLogEntry>();
|
||||
expectTypeOf<DaemonLogEntry>().toMatchTypeOf<LogEntry>();
|
||||
});
|
||||
|
||||
it("LogQuery is assignable both ways", () => {
|
||||
expectTypeOf<LogQuery>().toMatchTypeOf<DaemonLogQuery>();
|
||||
expectTypeOf<DaemonLogQuery>().toMatchTypeOf<LogQuery>();
|
||||
});
|
||||
|
||||
it("LogStore has all required methods", () => {
|
||||
expectTypeOf<LogStore>().toMatchTypeOf<Pick<DaemonLogStore, "query" | "getWorkflowRun" | "getActiveWorkflowRuns" | "getAllWorkflowRuns" | "upsertWorkflowRun" | "close">>();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<voi
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPackageManager(): Promise<{ cmd: string; args: string[] }> {
|
||||
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<void> {
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,99 +1,42 @@
|
||||
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<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);
|
||||
function waitForSocket(socketPath: string, timeoutMs = 5000, intervalMs = 200): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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, {
|
||||
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<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;
|
||||
}
|
||||
|
||||
/** 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 +44,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<void> {
|
||||
const { createKernel } = await loadDaemonModule(nerveRoot);
|
||||
await runForegroundKernelSession(nerveRoot, createKernel);
|
||||
}
|
||||
|
||||
async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
if (isRunning()) {
|
||||
const pid = readPidFile();
|
||||
@@ -108,12 +68,6 @@ async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
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 +78,13 @@ async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
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 +96,18 @@ async function runDaemon(nerveRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
writePidFile(pid);
|
||||
|
||||
const { getSocketPath } = await import("../workspace.js");
|
||||
const ready = await waitForSocket(getSocketPath(), 5000);
|
||||
|
||||
if (!ready || !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");
|
||||
|
||||
@@ -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<LogStore> {
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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 =
|
||||
| "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<LogEntry, "id">, run: WorkflowRun) => LogEntry;
|
||||
close: () => void;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string>;
|
||||
ready: Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export async function runForegroundKernelSession(
|
||||
nerveRoot: string,
|
||||
createKernel: CreateKernelFn,
|
||||
): 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, {
|
||||
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<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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } 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 | 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): string {
|
||||
const entry = getDaemonEntryPath(nerveRoot);
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** 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<string>;
|
||||
ready: Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
createLogStore: (dbPath: string) => LogStore;
|
||||
};
|
||||
|
||||
export async function loadDaemonModule(nerveRoot: string): Promise<DaemonModule> {
|
||||
const entry = assertWorkspaceDaemonInstalled(nerveRoot);
|
||||
const url = pathToFileURL(entry).href;
|
||||
return import(url) as Promise<DaemonModule>;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
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,
|
||||
banner: {
|
||||
js: "#!/usr/bin/env node",
|
||||
},
|
||||
/** Daemon is loaded from workspace node_modules at runtime — never bundle it. */
|
||||
external: ["@uncaged/nerve-daemon"],
|
||||
});
|
||||
|
||||
Generated
+3
-3
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user