From 8b88bfa0f616e940bd215bb0617533be88096888 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:08:03 -0600 Subject: [PATCH 1/2] feat(kernel-daemon): add daemon command handlers Add 12 command handlers registered via `registerDaemonCommands`: start, stop, restart, status, flush, pid, logs, launch, view, invoke, inspect, url-issue, and url-redeem. Each command follows a uniform `DaemonCommand` type and returns JSON-serializable results. The `view` command returns structured JSON for CLI consumption. Co-Authored-By: Claude Opus 4.6 --- packages/kernel-daemon/package.json | 4 +- .../kernel-daemon/src/commands/flush.test.ts | 45 ++++ packages/kernel-daemon/src/commands/flush.ts | 13 ++ packages/kernel-daemon/src/commands/index.ts | 212 ++++++++++++++++++ .../src/commands/inspect.test.ts | 133 +++++++++++ .../kernel-daemon/src/commands/inspect.ts | 66 ++++++ packages/kernel-daemon/src/commands/invoke.ts | 51 +++++ packages/kernel-daemon/src/commands/launch.ts | 49 ++++ packages/kernel-daemon/src/commands/logs.ts | 19 ++ packages/kernel-daemon/src/commands/pid.ts | 19 ++ .../src/commands/restart.test.ts | 63 ++++++ .../kernel-daemon/src/commands/restart.ts | 30 +++ packages/kernel-daemon/src/commands/start.ts | 17 ++ .../kernel-daemon/src/commands/status.test.ts | 134 +++++++++++ packages/kernel-daemon/src/commands/status.ts | 66 ++++++ packages/kernel-daemon/src/commands/stop.ts | 13 ++ packages/kernel-daemon/src/commands/types.ts | 16 ++ .../src/commands/url-issue.test.ts | 53 +++++ .../kernel-daemon/src/commands/url-issue.ts | 28 +++ .../src/commands/url-redeem.test.ts | 59 +++++ .../kernel-daemon/src/commands/url-redeem.ts | 28 +++ .../kernel-daemon/src/commands/view.test.ts | 94 ++++++++ packages/kernel-daemon/src/commands/view.ts | 45 ++++ packages/kernel-daemon/src/index.ts | 2 + yarn.lock | 2 + 25 files changed, 1260 insertions(+), 1 deletion(-) create mode 100644 packages/kernel-daemon/src/commands/flush.test.ts create mode 100644 packages/kernel-daemon/src/commands/flush.ts create mode 100644 packages/kernel-daemon/src/commands/index.ts create mode 100644 packages/kernel-daemon/src/commands/inspect.test.ts create mode 100644 packages/kernel-daemon/src/commands/inspect.ts create mode 100644 packages/kernel-daemon/src/commands/invoke.ts create mode 100644 packages/kernel-daemon/src/commands/launch.ts create mode 100644 packages/kernel-daemon/src/commands/logs.ts create mode 100644 packages/kernel-daemon/src/commands/pid.ts create mode 100644 packages/kernel-daemon/src/commands/restart.test.ts create mode 100644 packages/kernel-daemon/src/commands/restart.ts create mode 100644 packages/kernel-daemon/src/commands/start.ts create mode 100644 packages/kernel-daemon/src/commands/status.test.ts create mode 100644 packages/kernel-daemon/src/commands/status.ts create mode 100644 packages/kernel-daemon/src/commands/stop.ts create mode 100644 packages/kernel-daemon/src/commands/types.ts create mode 100644 packages/kernel-daemon/src/commands/url-issue.test.ts create mode 100644 packages/kernel-daemon/src/commands/url-issue.ts create mode 100644 packages/kernel-daemon/src/commands/url-redeem.test.ts create mode 100644 packages/kernel-daemon/src/commands/url-redeem.ts create mode 100644 packages/kernel-daemon/src/commands/view.test.ts create mode 100644 packages/kernel-daemon/src/commands/view.ts diff --git a/packages/kernel-daemon/package.json b/packages/kernel-daemon/package.json index 80be05133..90e1c2fed 100644 --- a/packages/kernel-daemon/package.json +++ b/packages/kernel-daemon/package.json @@ -45,6 +45,7 @@ "@ts-bridge/cli": "^0.6.3", "@ts-bridge/shims": "^0.1.1", "@types/node": "^22.13.1", + "@types/yargs": "^17.0.33", "depcheck": "^1.4.7", "eslint": "^9.23.0", "prettier": "^3.5.3", @@ -52,7 +53,8 @@ "ses": "^1.14.0", "typescript": "~5.8.2", "vite": "^7.3.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "yargs": "^17.7.2" }, "engines": { "node": "^20.11 || >=22" diff --git a/packages/kernel-daemon/src/commands/flush.test.ts b/packages/kernel-daemon/src/commands/flush.test.ts new file mode 100644 index 000000000..1268fd7b9 --- /dev/null +++ b/packages/kernel-daemon/src/commands/flush.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Mock } from 'vitest'; + +import { handleDaemonFlush } from './flush.ts'; + +vi.mock('../daemon-lifecycle.ts', () => ({ + flushDaemonStore: vi.fn(), +})); + +const makeLogger = () => + ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }) as never; + +describe('daemon-flush', () => { + let flushDaemonStore: Mock; + + beforeEach(async () => { + vi.clearAllMocks(); + const lifecycle = await import('../daemon-lifecycle.ts'); + flushDaemonStore = lifecycle.flushDaemonStore as Mock; + }); + + it('delegates to flushDaemonStore', async () => { + flushDaemonStore.mockResolvedValue(undefined); + const logger = makeLogger(); + + await handleDaemonFlush(logger); + + expect(flushDaemonStore).toHaveBeenCalledWith(logger); + }); + + it('propagates errors', async () => { + flushDaemonStore.mockRejectedValue( + new Error('Cannot flush while daemon is running'), + ); + + await expect(handleDaemonFlush(makeLogger())).rejects.toThrow( + 'Cannot flush while daemon is running', + ); + }); +}); diff --git a/packages/kernel-daemon/src/commands/flush.ts b/packages/kernel-daemon/src/commands/flush.ts new file mode 100644 index 000000000..6fbee343e --- /dev/null +++ b/packages/kernel-daemon/src/commands/flush.ts @@ -0,0 +1,13 @@ +import type { Logger } from '@metamask/logger'; + +import { flushDaemonStore } from '../daemon-lifecycle.ts'; + +/** + * Handle the `kernel daemon flush` command. + * Deletes the daemon database (daemon must be stopped). + * + * @param logger - Logger for output. + */ +export async function handleDaemonFlush(logger: Logger): Promise { + await flushDaemonStore(logger); +} diff --git a/packages/kernel-daemon/src/commands/index.ts b/packages/kernel-daemon/src/commands/index.ts new file mode 100644 index 000000000..4be7c968f --- /dev/null +++ b/packages/kernel-daemon/src/commands/index.ts @@ -0,0 +1,212 @@ +import type { Argv } from 'yargs'; + +import { handleDaemonFlush } from './flush.ts'; +import { handleInspect } from './inspect.ts'; +import { handleInvoke } from './invoke.ts'; +import { handleLaunch } from './launch.ts'; +import { handleDaemonLogs } from './logs.ts'; +import { handleDaemonPid } from './pid.ts'; +import { handleDaemonRestart } from './restart.ts'; +import { handleDaemonStart } from './start.ts'; +import { handleDaemonStatus } from './status.ts'; +import { handleDaemonStop } from './stop.ts'; +import type { DaemonCommandsConfig } from './types.ts'; +import { handleUrlIssue } from './url-issue.ts'; +import { handleUrlRedeem } from './url-redeem.ts'; +import { handleView } from './view.ts'; + +/** + * Run a daemon command handler and exit the process on completion. + * Errors propagate to yargs' fail handler; successful completion exits with 0. + * + * @param fn - The async handler to run. + * @returns A promise that exits the process on success. + */ +async function runAndExit(fn: () => Promise): Promise { + await fn(); + // eslint-disable-next-line n/no-process-exit + process.exit(0); +} + +/** + * Register all daemon subcommands on the given yargs instance. + * Captures config in closure so individual handlers receive injected dependencies. + * Every handler exits the process with code 0 after completing successfully. + * + * @param yargs - The yargs instance to extend (the `daemon` subcommand builder). + * @param config - Injected configuration (logger, getMethodSpecs, daemonProcessPath). + * @returns The extended yargs instance. + */ +export function registerDaemonCommands( + yargs: Argv, + config: DaemonCommandsConfig, +): Argv { + const { logger, getMethodSpecs, daemonProcessPath } = config; + + return yargs + .command( + 'start', + 'Start the background kernel daemon', + (yg) => yg, + async () => + runAndExit(async () => { + try { + await handleDaemonStart(daemonProcessPath, logger); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith('Daemon already running') + ) { + logger.info(error.message); + } else { + throw error; + } + } + }), + ) + .command( + 'stop', + 'Stop the background kernel daemon', + (yg) => yg, + async () => runAndExit(async () => handleDaemonStop(logger)), + ) + .command( + 'status', + 'Show daemon status', + (yg) => yg, + async () => runAndExit(async () => handleDaemonStatus(logger)), + ) + .command( + 'restart', + 'Restart the background kernel daemon', + (yg) => + yg.option('flush', { + type: 'boolean', + default: false, + describe: 'Flush the daemon database before restarting', + }), + async (args) => + runAndExit(async () => + handleDaemonRestart(daemonProcessPath, logger, { + flush: args.flush, + }), + ), + ) + .command( + 'flush', + 'Delete the daemon database (daemon must be stopped)', + (yg) => yg, + async () => runAndExit(async () => handleDaemonFlush(logger)), + ) + .command( + 'pid', + 'Print the daemon process ID', + (yg) => yg, + async () => runAndExit(async () => handleDaemonPid(logger)), + ) + .command( + 'logs', + 'Print the daemon log file', + (yg) => yg, + async () => runAndExit(async () => handleDaemonLogs(logger)), + ) + .command( + 'launch ', + 'Launch a .bundle or subcluster.json via the daemon', + (yg) => + yg.positional('path', { + type: 'string', + demandOption: true, + describe: 'Path to a .bundle or subcluster.json file', + }), + async (args) => + runAndExit(async () => handleLaunch(args.path, getMethodSpecs, logger)), + ) + .command( + 'view', + 'View kernel state as JSON', + (yg) => yg, + async () => runAndExit(async () => handleView(getMethodSpecs, logger)), + ) + .command( + 'invoke [args..]', + 'Invoke a method on a kernel object via the daemon', + (yg) => + yg + .positional('kref', { + type: 'string', + demandOption: true, + describe: 'The kernel reference (e.g. ko1)', + }) + .positional('method', { + type: 'string', + demandOption: true, + describe: 'The method name to invoke', + }) + .positional('args', { + type: 'string', + array: true, + default: [] as string[], + describe: 'Arguments to pass (JSON-parsed if possible)', + }), + async (args) => + runAndExit(async () => + handleInvoke( + args.kref, + args.method, + args.args ?? [], + getMethodSpecs, + logger, + ), + ), + ) + .command( + 'inspect ', + 'Inspect a kernel object (methods, guard, schema)', + (yg) => + yg.positional('kref', { + type: 'string', + demandOption: true, + describe: 'The kernel reference (e.g. ko1)', + }), + async (args) => + runAndExit(async () => + handleInspect(args.kref, getMethodSpecs, logger), + ), + ) + .command('url [command]', 'Issue and redeem OCAP URLs', (yg) => + yg + .command( + 'issue ', + 'Issue an OCAP URL for a kernel object', + (yg2) => + yg2.positional('kref', { + type: 'string', + demandOption: true, + describe: 'The kernel reference (e.g. ko1)', + }), + async (args) => + runAndExit(async () => + handleUrlIssue(args.kref, getMethodSpecs, logger), + ), + ) + .command( + 'redeem ', + 'Redeem an OCAP URL to get its kernel reference', + (yg2) => + yg2.positional('url', { + type: 'string', + demandOption: true, + describe: 'The OCAP URL to redeem', + }), + async (args) => + runAndExit(async () => + handleUrlRedeem(args.url, getMethodSpecs, logger), + ), + ) + .demandCommand(1, 'Specify a url subcommand: issue or redeem'), + ); +} + +export { handleDaemonStart } from './start.ts'; +export type { DaemonCommandsConfig } from './types.ts'; diff --git a/packages/kernel-daemon/src/commands/inspect.test.ts b/packages/kernel-daemon/src/commands/inspect.test.ts new file mode 100644 index 000000000..954c2eca5 --- /dev/null +++ b/packages/kernel-daemon/src/commands/inspect.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { handleInspect } from './inspect.ts'; + +const { mockCall, mockClose } = vi.hoisted(() => ({ + mockCall: vi.fn(), + mockClose: vi.fn(), +})); + +vi.mock('../daemon-client.ts', () => ({ + connectToDaemon: vi.fn().mockReturnValue({ + client: { call: mockCall }, + close: mockClose, + }), +})); + +const makeLogger = () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}); + +const mockGetMethodSpecs = vi.fn().mockResolvedValue({}); + +/** + * Build a mock capdata result with a smallcaps-encoded body. + * + * @param value - The value to encode. + * @returns A capdata object with body and slots. + */ +function makeCapData(value: unknown) { + return { body: `#${JSON.stringify(value)}`, slots: [] }; +} + +describe('handleInspect', () => { + let logger: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + logger = makeLogger(); + }); + + it('returns all keys when object has methods, guard, and describe', async () => { + const methodNames = [ + 'hello', + 'describe', + '__getMethodNames__', + '__getInterfaceGuard__', + ]; + const guard = { methods: { hello: {} } }; + const schema = { hello: { description: 'says hello' } }; + + mockCall + .mockResolvedValueOnce(makeCapData(methodNames)) + .mockResolvedValueOnce(makeCapData(guard)) + .mockResolvedValueOnce(makeCapData(schema)); + + await handleInspect('ko1', mockGetMethodSpecs, logger as never); + + expect(mockCall).toHaveBeenCalledTimes(3); + expect(mockCall).toHaveBeenNthCalledWith(1, 'queueMessage', [ + 'ko1', + '__getMethodNames__', + [], + ]); + expect(mockCall).toHaveBeenNthCalledWith(2, 'queueMessage', [ + 'ko1', + '__getInterfaceGuard__', + [], + ]); + expect(mockCall).toHaveBeenNthCalledWith(3, 'queueMessage', [ + 'ko1', + 'describe', + [], + ]); + + const logged = JSON.parse(logger.info.mock.calls[0][0] as string); + expect(logged).toStrictEqual({ + methodNames, + interfaceGuard: guard, + schema, + }); + expect(mockClose).toHaveBeenCalled(); + }); + + it('returns only methodNames when object has no guard or describe', async () => { + const methodNames = ['hello', '__getMethodNames__']; + + mockCall.mockResolvedValueOnce(makeCapData(methodNames)); + + await handleInspect('ko5', mockGetMethodSpecs, logger as never); + + expect(mockCall).toHaveBeenCalledTimes(1); + + const logged = JSON.parse(logger.info.mock.calls[0][0] as string); + expect(logged).toStrictEqual({ methodNames }); + expect(mockClose).toHaveBeenCalled(); + }); + + it('omits interfaceGuard when object has describe but no guard', async () => { + const methodNames = ['hello', 'describe', '__getMethodNames__']; + const schema = { hello: { description: 'says hello' } }; + + mockCall + .mockResolvedValueOnce(makeCapData(methodNames)) + .mockResolvedValueOnce(makeCapData(schema)); + + await handleInspect('ko3', mockGetMethodSpecs, logger as never); + + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockCall).toHaveBeenNthCalledWith(2, 'queueMessage', [ + 'ko3', + 'describe', + [], + ]); + + const logged = JSON.parse(logger.info.mock.calls[0][0] as string); + expect(logged).toStrictEqual({ methodNames, schema }); + expect(logged).not.toHaveProperty('interfaceGuard'); + expect(mockClose).toHaveBeenCalled(); + }); + + it('closes the connection on error', async () => { + mockCall.mockRejectedValue(new Error('connection failed')); + + await expect( + handleInspect('ko1', mockGetMethodSpecs, logger as never), + ).rejects.toThrow('connection failed'); + + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-daemon/src/commands/inspect.ts b/packages/kernel-daemon/src/commands/inspect.ts new file mode 100644 index 000000000..77ba183d2 --- /dev/null +++ b/packages/kernel-daemon/src/commands/inspect.ts @@ -0,0 +1,66 @@ +import type { Logger } from '@metamask/logger'; + +import { connectToDaemon } from '../daemon-client.ts'; +import type { GetMethodSpecs } from './types.ts'; + +type CapDataResult = { body: string; slots: string[] }; + +/** + * Parse a smallcaps-encoded capdata body into a plain value. + * + * @param body - The smallcaps-encoded body string (prefixed with `#`). + * @returns The parsed value. + */ +function parseCapDataBody(body: string): unknown { + return JSON.parse(body.slice(1)); +} + +/** + * Handle the `kernel daemon inspect ` command. + * Queries a kernel object for its method names, interface guard, and schema. + * + * @param kref - The kernel reference to inspect. + * @param getMethodSpecs - Async getter for RPC method specifications. + * @param logger - Logger for output. + */ +export async function handleInspect( + kref: string, + getMethodSpecs: GetMethodSpecs, + logger: Logger, +): Promise { + const methodSpecs = await getMethodSpecs(); + const { client, close } = await connectToDaemon(methodSpecs, logger); + + try { + const namesResult = (await client.call('queueMessage', [ + kref, + '__getMethodNames__', + [], + ] as never)) as CapDataResult; + const methodNames = parseCapDataBody(namesResult.body) as string[]; + + const result: Record = { methodNames }; + + if (methodNames.includes('__getInterfaceGuard__')) { + const guardResult = (await client.call('queueMessage', [ + kref, + '__getInterfaceGuard__', + [], + ] as never)) as CapDataResult; + result.interfaceGuard = parseCapDataBody(guardResult.body); + } + + if (methodNames.includes('describe')) { + const schemaResult = (await client.call('queueMessage', [ + kref, + 'describe', + [], + ] as never)) as CapDataResult; + result.schema = parseCapDataBody(schemaResult.body); + } + + logger.info(JSON.stringify(result, null, 2)); + } finally { + close(); + } +} diff --git a/packages/kernel-daemon/src/commands/invoke.ts b/packages/kernel-daemon/src/commands/invoke.ts new file mode 100644 index 000000000..8a3dc7c66 --- /dev/null +++ b/packages/kernel-daemon/src/commands/invoke.ts @@ -0,0 +1,51 @@ +import type { Logger } from '@metamask/logger'; + +import { connectToDaemon } from '../daemon-client.ts'; +import type { GetMethodSpecs } from './types.ts'; + +/** + * Attempt to parse a string as JSON, falling back to the raw string. + * + * @param value - The string value to try parsing. + * @returns The parsed JSON value, or the original string. + */ +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +/** + * Handle the `kernel daemon invoke [...args]` command. + * Sends a message to the specified kernel object via the daemon. + * + * @param kref - The kernel reference to target. + * @param method - The method name to invoke. + * @param args - Arguments to pass, JSON-parsed where possible. + * @param getMethodSpecs - Async getter for RPC method specifications. + * @param logger - Logger for output. + */ +export async function handleInvoke( + kref: string, + method: string, + args: string[], + getMethodSpecs: GetMethodSpecs, + logger: Logger, +): Promise { + const methodSpecs = await getMethodSpecs(); + const { client, close } = await connectToDaemon(methodSpecs, logger); + + try { + const parsedArgs = args.map(tryParseJson); + const result = await client.call('queueMessage', [ + kref, + method, + parsedArgs, + ] as never); + logger.info(JSON.stringify(result, null, 2)); + } finally { + close(); + } +} diff --git a/packages/kernel-daemon/src/commands/launch.ts b/packages/kernel-daemon/src/commands/launch.ts new file mode 100644 index 000000000..b95b44ea5 --- /dev/null +++ b/packages/kernel-daemon/src/commands/launch.ts @@ -0,0 +1,49 @@ +import type { Logger } from '@metamask/logger'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { connectToDaemon } from '../daemon-client.ts'; +import type { GetMethodSpecs } from './types.ts'; + +/** + * Handle the `kernel daemon launch ` command. + * Reads a .bundle or subcluster.json and launches it via the daemon. + * + * @param filePath - Path to the file to launch. + * @param getMethodSpecs - Async getter for RPC method specifications. + * @param logger - Logger for output. + */ +export async function handleLaunch( + filePath: string, + getMethodSpecs: GetMethodSpecs, + logger: Logger, +): Promise { + const resolved = path.resolve(filePath); + const content = await readFile(resolved, 'utf-8'); + + let config: Record; + if (resolved.endsWith('.json')) { + config = JSON.parse(content); + } else if (resolved.endsWith('.bundle')) { + config = { + bootstrap: 'main', + vats: { main: { bundleSpec: `file://${resolved}` } }, + }; + } else { + throw new Error( + `Unsupported file type: ${path.extname(resolved)}. Expected .bundle or .json`, + ); + } + + const methodSpecs = await getMethodSpecs(); + const { client, close } = await connectToDaemon(methodSpecs, logger); + try { + const result = (await client.call('launchSubcluster', { + config, + })) as { subclusterId: string; bootstrapRootKref: string }; + logger.info(`Subcluster launched: ${result.subclusterId}`); + logger.info(`Bootstrap root kref: ${result.bootstrapRootKref}`); + } finally { + close(); + } +} diff --git a/packages/kernel-daemon/src/commands/logs.ts b/packages/kernel-daemon/src/commands/logs.ts new file mode 100644 index 000000000..5379b1937 --- /dev/null +++ b/packages/kernel-daemon/src/commands/logs.ts @@ -0,0 +1,19 @@ +import type { Logger } from '@metamask/logger'; +import { readFile } from 'node:fs/promises'; + +import { LOG_FILE } from '../constants.ts'; + +/** + * Handle the `kernel daemon logs` command. + * Prints the contents of the daemon log file. + * + * @param logger - Logger for output. + */ +export async function handleDaemonLogs(logger: Logger): Promise { + try { + const content = await readFile(LOG_FILE, 'utf-8'); + logger.info(content); + } catch { + logger.info('No daemon log file found'); + } +} diff --git a/packages/kernel-daemon/src/commands/pid.ts b/packages/kernel-daemon/src/commands/pid.ts new file mode 100644 index 000000000..c5911351b --- /dev/null +++ b/packages/kernel-daemon/src/commands/pid.ts @@ -0,0 +1,19 @@ +import type { Logger } from '@metamask/logger'; + +import { isDaemonRunning, readDaemonPid } from '../daemon-lifecycle.ts'; + +/** + * Handle the `kernel daemon pid` command. + * Prints the daemon PID and whether it is running. + * + * @param logger - Logger for output. + */ +export async function handleDaemonPid(logger: Logger): Promise { + const pid = await readDaemonPid(); + if (pid === null) { + logger.info('No daemon PID file found'); + return; + } + const running = await isDaemonRunning(); + logger.info(`${pid}${running ? '' : ' (not running)'}`); +} diff --git a/packages/kernel-daemon/src/commands/restart.test.ts b/packages/kernel-daemon/src/commands/restart.test.ts new file mode 100644 index 000000000..da2d53ede --- /dev/null +++ b/packages/kernel-daemon/src/commands/restart.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Mock } from 'vitest'; + +import { handleDaemonRestart } from './restart.ts'; + +vi.mock('../daemon-lifecycle.ts', () => ({ + stopDaemon: vi.fn(), + startDaemon: vi.fn(), + flushDaemonStore: vi.fn(), +})); + +const makeLogger = () => + ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }) as never; + +describe('daemon-restart', () => { + let stopDaemon: Mock; + let startDaemon: Mock; + let flushDaemonStore: Mock; + + beforeEach(async () => { + vi.clearAllMocks(); + const lifecycle = await import('../daemon-lifecycle.ts'); + stopDaemon = lifecycle.stopDaemon as Mock; + startDaemon = lifecycle.startDaemon as Mock; + flushDaemonStore = lifecycle.flushDaemonStore as Mock; + }); + + it('stops then starts without flush by default', async () => { + stopDaemon.mockResolvedValue(undefined); + startDaemon.mockResolvedValue(42); + + await handleDaemonRestart('/path/to/daemon.mjs', makeLogger()); + + expect(stopDaemon).toHaveBeenCalledTimes(1); + expect(startDaemon).toHaveBeenCalledTimes(1); + expect(flushDaemonStore).not.toHaveBeenCalled(); + }); + + it('flushes between stop and start when flush is true', async () => { + const callOrder: string[] = []; + stopDaemon.mockImplementation(async () => { + callOrder.push('stop'); + }); + flushDaemonStore.mockImplementation(async () => { + callOrder.push('flush'); + }); + startDaemon.mockImplementation(async () => { + callOrder.push('start'); + return 42; + }); + + await handleDaemonRestart('/path/to/daemon.mjs', makeLogger(), { + flush: true, + }); + + expect(callOrder).toStrictEqual(['stop', 'flush', 'start']); + }); +}); diff --git a/packages/kernel-daemon/src/commands/restart.ts b/packages/kernel-daemon/src/commands/restart.ts new file mode 100644 index 000000000..ea0fa4b64 --- /dev/null +++ b/packages/kernel-daemon/src/commands/restart.ts @@ -0,0 +1,30 @@ +import type { Logger } from '@metamask/logger'; + +import { + flushDaemonStore, + startDaemon, + stopDaemon, +} from '../daemon-lifecycle.ts'; + +/** + * Handle the `kernel daemon restart` command. + * Stops the running daemon and starts a new one. + * + * @param daemonProcessPath - Absolute path to the daemon process entry point script. + * @param logger - Logger for output. + * @param options - Options bag. + * @param options.flush - If true, flush the daemon store between stop and start. + */ +export async function handleDaemonRestart( + daemonProcessPath: string, + logger: Logger, + { flush = false }: { flush?: boolean } = {}, +): Promise { + await stopDaemon(logger); + + if (flush) { + await flushDaemonStore(logger); + } + + await startDaemon(daemonProcessPath, logger); +} diff --git a/packages/kernel-daemon/src/commands/start.ts b/packages/kernel-daemon/src/commands/start.ts new file mode 100644 index 000000000..40dc8c7af --- /dev/null +++ b/packages/kernel-daemon/src/commands/start.ts @@ -0,0 +1,17 @@ +import type { Logger } from '@metamask/logger'; + +import { startDaemon } from '../daemon-lifecycle.ts'; + +/** + * Handle the `kernel daemon start` command. + * Forks a background daemon process and prints the PID. + * + * @param daemonProcessPath - Absolute path to the daemon process entry point script. + * @param logger - Logger for output. + */ +export async function handleDaemonStart( + daemonProcessPath: string, + logger: Logger, +): Promise { + await startDaemon(daemonProcessPath, logger); +} diff --git a/packages/kernel-daemon/src/commands/status.test.ts b/packages/kernel-daemon/src/commands/status.test.ts new file mode 100644 index 000000000..520b0f44a --- /dev/null +++ b/packages/kernel-daemon/src/commands/status.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Mock } from 'vitest'; + +import { formatUptime, handleDaemonStatus } from './status.ts'; + +vi.mock('node:fs/promises', () => ({ + access: vi.fn(), + stat: vi.fn(), +})); + +vi.mock('node:os', () => ({ + homedir: () => '/mock-home', +})); + +vi.mock('../daemon-lifecycle.ts', () => ({ + isDaemonRunning: vi.fn(), + readDaemonPid: vi.fn(), +})); + +vi.mock('../constants.ts', () => ({ + PID_FILE: '/mock-home/.ocap-kernel-daemon/daemon.pid', + SOCK_FILE: '/mock-home/.ocap-kernel-daemon/daemon.sock', +})); + +const makeLogger = () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}); + +describe('daemon-status', () => { + describe('formatUptime', () => { + it('formats seconds only', () => { + expect(formatUptime(45_000)).toBe('45s'); + }); + + it('formats minutes and seconds', () => { + expect(formatUptime(135_000)).toBe('2m 15s'); + }); + + it('formats hours, minutes, and seconds', () => { + expect(formatUptime(8_103_000)).toBe('2h 15m 3s'); + }); + + it('formats days, hours, minutes, and seconds', () => { + expect(formatUptime(90_063_000)).toBe('1d 1h 1m 3s'); + }); + + it('formats zero', () => { + expect(formatUptime(0)).toBe('0s'); + }); + }); + + describe('handleDaemonStatus', () => { + let logger: ReturnType; + let isDaemonRunning: Mock; + let readDaemonPid: Mock; + let stat: Mock; + let access: Mock; + + beforeEach(async () => { + vi.clearAllMocks(); + logger = makeLogger(); + const lifecycle = await import('../daemon-lifecycle.ts'); + isDaemonRunning = lifecycle.isDaemonRunning as Mock; + readDaemonPid = lifecycle.readDaemonPid as Mock; + const fs = await import('node:fs/promises'); + stat = fs.stat as Mock; + access = fs.access as Mock; + }); + + it('logs stopped when daemon is not running', async () => { + isDaemonRunning.mockResolvedValue(false); + + await handleDaemonStatus(logger as never); + + expect(logger.info).toHaveBeenCalledWith('Status: stopped'); + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it('logs running status with PID, uptime, and socket path', async () => { + isDaemonRunning.mockResolvedValue(true); + readDaemonPid.mockResolvedValue(12345); + stat.mockResolvedValue({ + birthtime: new Date(Date.now() - 8_103_000), + }); + access.mockResolvedValue(undefined); + + await handleDaemonStatus(logger as never); + + expect(logger.info).toHaveBeenCalledWith('Status: running'); + expect(logger.info).toHaveBeenCalledWith('PID: 12345'); + expect(logger.info).toHaveBeenCalledWith( + expect.stringMatching(/^Uptime: 2h 15m/u), + ); + expect(logger.info).toHaveBeenCalledWith( + 'Socket: ~/.ocap-kernel-daemon/daemon.sock', + ); + }); + + it('logs "not found" when socket file is missing', async () => { + isDaemonRunning.mockResolvedValue(true); + readDaemonPid.mockResolvedValue(99); + stat.mockResolvedValue({ + birthtime: new Date(Date.now() - 5000), + }); + access.mockRejectedValue(new Error('ENOENT')); + + await handleDaemonStatus(logger as never); + + expect(logger.info).toHaveBeenCalledWith('Status: running'); + expect(logger.info).toHaveBeenCalledWith('Socket: not found'); + }); + + it('skips uptime when PID file stat fails', async () => { + isDaemonRunning.mockResolvedValue(true); + readDaemonPid.mockResolvedValue(42); + stat.mockRejectedValue(new Error('ENOENT')); + access.mockResolvedValue(undefined); + + await handleDaemonStatus(logger as never); + + expect(logger.info).toHaveBeenCalledWith('Status: running'); + expect(logger.info).toHaveBeenCalledWith('PID: 42'); + expect(logger.info).not.toHaveBeenCalledWith( + expect.stringContaining('Uptime'), + ); + expect(logger.info).toHaveBeenCalledWith( + 'Socket: ~/.ocap-kernel-daemon/daemon.sock', + ); + }); + }); +}); diff --git a/packages/kernel-daemon/src/commands/status.ts b/packages/kernel-daemon/src/commands/status.ts new file mode 100644 index 000000000..894d0b93e --- /dev/null +++ b/packages/kernel-daemon/src/commands/status.ts @@ -0,0 +1,66 @@ +import type { Logger } from '@metamask/logger'; +import { access, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; + +import { PID_FILE, SOCK_FILE } from '../constants.ts'; +import { isDaemonRunning, readDaemonPid } from '../daemon-lifecycle.ts'; + +/** + * Format a duration in milliseconds as a human-readable string. + * + * @param ms - Duration in milliseconds. + * @returns A string like "2h 15m 3s". + */ +export function formatUptime(ms: number): string { + const seconds = Math.floor(ms / 1000) % 60; + const minutes = Math.floor(ms / (1000 * 60)) % 60; + const hours = Math.floor(ms / (1000 * 60 * 60)) % 24; + const days = Math.floor(ms / (1000 * 60 * 60 * 24)); + + const parts: string[] = []; + if (days > 0) { + parts.push(`${days}d`); + } + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + parts.push(`${seconds}s`); + return parts.join(' '); +} + +/** + * Handle the `kernel daemon status` command. + * Prints a concise status summary of the daemon. + * + * @param logger - Logger for output. + */ +export async function handleDaemonStatus(logger: Logger): Promise { + const running = await isDaemonRunning(); + if (!running) { + logger.info('Status: stopped'); + return; + } + + const pid = await readDaemonPid(); + logger.info('Status: running'); + logger.info(`PID: ${pid}`); + + try { + const pidStat = await stat(PID_FILE); + const uptime = Date.now() - pidStat.birthtime.getTime(); + logger.info(`Uptime: ${formatUptime(uptime)}`); + } catch { + // PID file stat failed; skip uptime + } + + try { + await access(SOCK_FILE); + const displayPath = SOCK_FILE.replace(homedir(), '~'); + logger.info(`Socket: ${displayPath}`); + } catch { + logger.info('Socket: not found'); + } +} diff --git a/packages/kernel-daemon/src/commands/stop.ts b/packages/kernel-daemon/src/commands/stop.ts new file mode 100644 index 000000000..b1b150424 --- /dev/null +++ b/packages/kernel-daemon/src/commands/stop.ts @@ -0,0 +1,13 @@ +import type { Logger } from '@metamask/logger'; + +import { stopDaemon } from '../daemon-lifecycle.ts'; + +/** + * Handle the `kernel daemon stop` command. + * Sends a shutdown RPC to the running daemon. + * + * @param logger - Logger for output. + */ +export async function handleDaemonStop(logger: Logger): Promise { + await stopDaemon(logger); +} diff --git a/packages/kernel-daemon/src/commands/types.ts b/packages/kernel-daemon/src/commands/types.ts new file mode 100644 index 000000000..38dca35d0 --- /dev/null +++ b/packages/kernel-daemon/src/commands/types.ts @@ -0,0 +1,16 @@ +import type { Logger } from '@metamask/logger'; + +/** + * Async getter for RPC method specifications. + * Injected to avoid cyclic dependency on kernel-browser-runtime. + */ +export type GetMethodSpecs = () => Promise>; + +/** + * Configuration for registering daemon commands on a yargs instance. + */ +export type DaemonCommandsConfig = { + logger: Logger; + getMethodSpecs: GetMethodSpecs; + daemonProcessPath: string; +}; diff --git a/packages/kernel-daemon/src/commands/url-issue.test.ts b/packages/kernel-daemon/src/commands/url-issue.test.ts new file mode 100644 index 000000000..fefe247d0 --- /dev/null +++ b/packages/kernel-daemon/src/commands/url-issue.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { handleUrlIssue } from './url-issue.ts'; + +const { mockCall, mockClose } = vi.hoisted(() => ({ + mockCall: vi.fn(), + mockClose: vi.fn(), +})); + +vi.mock('../daemon-client.ts', () => ({ + connectToDaemon: vi.fn().mockReturnValue({ + client: { call: mockCall }, + close: mockClose, + }), +})); + +const makeLogger = () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}); + +const mockGetMethodSpecs = vi.fn().mockResolvedValue({}); + +describe('handleUrlIssue', () => { + let logger: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + logger = makeLogger(); + }); + + it('calls issueOcapURL RPC with the given kref and logs the result', async () => { + mockCall.mockResolvedValue('ocap://peer123/ko1'); + + await handleUrlIssue('ko1', mockGetMethodSpecs, logger as never); + + expect(mockCall).toHaveBeenCalledWith('issueOcapURL', { kref: 'ko1' }); + expect(logger.info).toHaveBeenCalledWith('ocap://peer123/ko1'); + expect(mockClose).toHaveBeenCalled(); + }); + + it('closes the connection on error', async () => { + mockCall.mockRejectedValue(new Error('Remote comms not initialized')); + + await expect( + handleUrlIssue('ko1', mockGetMethodSpecs, logger as never), + ).rejects.toThrow('Remote comms not initialized'); + + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-daemon/src/commands/url-issue.ts b/packages/kernel-daemon/src/commands/url-issue.ts new file mode 100644 index 000000000..a902fcc92 --- /dev/null +++ b/packages/kernel-daemon/src/commands/url-issue.ts @@ -0,0 +1,28 @@ +import type { Logger } from '@metamask/logger'; + +import { connectToDaemon } from '../daemon-client.ts'; +import type { GetMethodSpecs } from './types.ts'; + +/** + * Handle the `kernel daemon url issue ` command. + * Issues an OCAP URL for the given kernel reference via the daemon. + * + * @param kref - The kernel reference to issue an OCAP URL for. + * @param getMethodSpecs - Async getter for RPC method specifications. + * @param logger - Logger for output. + */ +export async function handleUrlIssue( + kref: string, + getMethodSpecs: GetMethodSpecs, + logger: Logger, +): Promise { + const methodSpecs = await getMethodSpecs(); + const { client, close } = await connectToDaemon(methodSpecs, logger); + + try { + const url = await client.call('issueOcapURL', { kref } as never); + logger.info(String(url)); + } finally { + close(); + } +} diff --git a/packages/kernel-daemon/src/commands/url-redeem.test.ts b/packages/kernel-daemon/src/commands/url-redeem.test.ts new file mode 100644 index 000000000..1c9c000d5 --- /dev/null +++ b/packages/kernel-daemon/src/commands/url-redeem.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { handleUrlRedeem } from './url-redeem.ts'; + +const { mockCall, mockClose } = vi.hoisted(() => ({ + mockCall: vi.fn(), + mockClose: vi.fn(), +})); + +vi.mock('../daemon-client.ts', () => ({ + connectToDaemon: vi.fn().mockReturnValue({ + client: { call: mockCall }, + close: mockClose, + }), +})); + +const makeLogger = () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}); + +const mockGetMethodSpecs = vi.fn().mockResolvedValue({}); + +describe('handleUrlRedeem', () => { + let logger: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + logger = makeLogger(); + }); + + it('calls redeemOcapURL RPC with the given url and logs the result', async () => { + mockCall.mockResolvedValue('ko42'); + + await handleUrlRedeem( + 'ocap://peer123/ko1', + mockGetMethodSpecs, + logger as never, + ); + + expect(mockCall).toHaveBeenCalledWith('redeemOcapURL', { + url: 'ocap://peer123/ko1', + }); + expect(logger.info).toHaveBeenCalledWith('ko42'); + expect(mockClose).toHaveBeenCalled(); + }); + + it('closes the connection on error', async () => { + mockCall.mockRejectedValue(new Error('Invalid OCAP URL')); + + await expect( + handleUrlRedeem('ocap://bad', mockGetMethodSpecs, logger as never), + ).rejects.toThrow('Invalid OCAP URL'); + + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-daemon/src/commands/url-redeem.ts b/packages/kernel-daemon/src/commands/url-redeem.ts new file mode 100644 index 000000000..615f01143 --- /dev/null +++ b/packages/kernel-daemon/src/commands/url-redeem.ts @@ -0,0 +1,28 @@ +import type { Logger } from '@metamask/logger'; + +import { connectToDaemon } from '../daemon-client.ts'; +import type { GetMethodSpecs } from './types.ts'; + +/** + * Handle the `kernel daemon url redeem ` command. + * Redeems an OCAP URL to get its kernel reference via the daemon. + * + * @param url - The OCAP URL to redeem. + * @param getMethodSpecs - Async getter for RPC method specifications. + * @param logger - Logger for output. + */ +export async function handleUrlRedeem( + url: string, + getMethodSpecs: GetMethodSpecs, + logger: Logger, +): Promise { + const methodSpecs = await getMethodSpecs(); + const { client, close } = await connectToDaemon(methodSpecs, logger); + + try { + const kref = await client.call('redeemOcapURL', { url } as never); + logger.info(String(kref)); + } finally { + close(); + } +} diff --git a/packages/kernel-daemon/src/commands/view.test.ts b/packages/kernel-daemon/src/commands/view.test.ts new file mode 100644 index 000000000..97656a510 --- /dev/null +++ b/packages/kernel-daemon/src/commands/view.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { handleView } from './view.ts'; + +const { mockCall, mockClose } = vi.hoisted(() => ({ + mockCall: vi.fn(), + mockClose: vi.fn(), +})); + +vi.mock('../daemon-client.ts', () => ({ + connectToDaemon: vi.fn().mockReturnValue({ + client: { call: mockCall }, + close: mockClose, + }), +})); + +const makeLogger = () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +}); + +const mockGetMethodSpecs = vi.fn().mockResolvedValue({}); + +describe('handleView', () => { + let logger: ReturnType; + let stdoutSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + logger = makeLogger(); + stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + }); + + it('writes all categories as a single JSON object to stdout', async () => { + mockCall.mockResolvedValue([ + { key: 'ko1', value: 'obj-val-1' }, + { key: 'ko2', value: 'obj-val-2' }, + { key: 'kp1', value: 'prom-val-1' }, + { key: 'v1', value: 'vat-val-1' }, + { key: 'v2', value: 'vat-val-2' }, + { key: 'other', value: 'ignored' }, + ]); + + await handleView(mockGetMethodSpecs, logger as never); + + expect(mockCall).toHaveBeenCalledWith('executeDBQuery', { + sql: 'SELECT key, value FROM kv', + }); + + const output = JSON.parse((stdoutSpy.mock.calls[0] as [string])[0]); + expect(output).toStrictEqual({ + objects: { ko1: 'obj-val-1', ko2: 'obj-val-2' }, + promises: { kp1: 'prom-val-1' }, + vats: { v1: 'vat-val-1', v2: 'vat-val-2' }, + }); + expect(mockClose).toHaveBeenCalled(); + }); + + it('produces empty categories when DB has no matching entries', async () => { + mockCall.mockResolvedValue([]); + + await handleView(mockGetMethodSpecs, logger as never); + + const output = JSON.parse((stdoutSpy.mock.calls[0] as [string])[0]); + expect(output).toStrictEqual({ + objects: {}, + promises: {}, + vats: {}, + }); + expect(mockClose).toHaveBeenCalled(); + }); + + it('does not log via logger', async () => { + mockCall.mockResolvedValue([{ key: 'ko1', value: 'val' }]); + + await handleView(mockGetMethodSpecs, logger as never); + + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('closes the daemon connection on error', async () => { + mockCall.mockRejectedValue(new Error('connection failed')); + + await expect( + handleView(mockGetMethodSpecs, logger as never), + ).rejects.toThrow('connection failed'); + + expect(mockClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/kernel-daemon/src/commands/view.ts b/packages/kernel-daemon/src/commands/view.ts new file mode 100644 index 000000000..df7826eac --- /dev/null +++ b/packages/kernel-daemon/src/commands/view.ts @@ -0,0 +1,45 @@ +import type { Logger } from '@metamask/logger'; + +import { connectToDaemon } from '../daemon-client.ts'; +import type { GetMethodSpecs } from './types.ts'; + +const categoryPrefixes: Record = { + objects: 'ko', + promises: 'kp', + vats: 'v', +}; + +/** + * Handle a `kernel daemon view` command. + * Queries the daemon's kernel database and outputs all kernel state + * (objects, promises, vats) as a JSON object to stdout. + * + * @param getMethodSpecs - Async getter for RPC method specifications. + * @param logger - Logger for diagnostics. + */ +export async function handleView( + getMethodSpecs: GetMethodSpecs, + logger: Logger, +): Promise { + const methodSpecs = await getMethodSpecs(); + const { client, close } = await connectToDaemon(methodSpecs, logger); + + try { + const entries = (await client.call('executeDBQuery', { + sql: 'SELECT key, value FROM kv', + })) as { key: string; value: string }[]; + + const result: Record> = {}; + for (const [category, prefix] of Object.entries(categoryPrefixes)) { + result[category] = Object.fromEntries( + entries + .filter((entry) => entry.key.startsWith(prefix)) + .map(({ key, value }) => [key, value]), + ); + } + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } finally { + close(); + } +} diff --git a/packages/kernel-daemon/src/index.ts b/packages/kernel-daemon/src/index.ts index 1e7e45bfe..c8f24f628 100644 --- a/packages/kernel-daemon/src/index.ts +++ b/packages/kernel-daemon/src/index.ts @@ -17,3 +17,5 @@ export { export { createDaemonServer } from './daemon-server.ts'; export type { RpcDispatcher } from './daemon-server.ts'; export type { DaemonConnection } from './types.ts'; +export { registerDaemonCommands, handleDaemonStart } from './commands/index.ts'; +export type { DaemonCommandsConfig } from './commands/types.ts'; diff --git a/yarn.lock b/yarn.lock index e01265ef3..9c7de43f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3611,6 +3611,7 @@ __metadata: "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" "@types/node": "npm:^22.13.1" + "@types/yargs": "npm:^17.0.33" depcheck: "npm:^1.4.7" eslint: "npm:^9.23.0" prettier: "npm:^3.5.3" @@ -3619,6 +3620,7 @@ __metadata: typescript: "npm:~5.8.2" vite: "npm:^7.3.0" vitest: "npm:^4.0.16" + yargs: "npm:^17.7.2" languageName: unknown linkType: soft From a75c945811a0ab56b364fb81e36d556515726aa1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:08:11 -0600 Subject: [PATCH 2/2] feat(cli): integrate daemon commands Wire daemon commands into the CLI via a new `kernel` command group. `daemon-process.ts` manages the daemon child process, and `kernel/index.ts` dispatches sub-commands (start, stop, status, etc.) through the IPC client. Adds e2e test scaffolding for daemon lifecycle. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 +- packages/cli/package.json | 3 + packages/cli/src/app.ts | 3 + .../cli/src/commands/kernel/daemon-process.ts | 153 +++++++++++++++++ packages/cli/src/commands/kernel/index.ts | 89 ++++++++++ packages/cli/test/e2e/daemon.test.ts | 154 ++++++++++++++++++ packages/cli/tsconfig.build.json | 6 +- packages/cli/tsconfig.json | 4 +- packages/cli/vitest.config.e2e.ts | 21 +++ yarn.lock | 4 +- 10 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/kernel/daemon-process.ts create mode 100644 packages/cli/src/commands/kernel/index.ts create mode 100644 packages/cli/test/e2e/daemon.test.ts create mode 100644 packages/cli/vitest.config.e2e.ts diff --git a/package.json b/package.json index e3251f5f1..e12ab3350 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "test:integration": "yarn workspaces foreach --all run test:integration", "test:verbose": "yarn test --reporter verbose", "test:watch": "vitest", - "why:batch": "./scripts/why-batch.sh" + "why:batch": "./scripts/why-batch.sh", + "root": "./scripts/echo-root.sh" }, "simple-git-hooks": { "pre-commit": "./scripts/pre-commit.sh", diff --git a/packages/cli/package.json b/packages/cli/package.json index 30210813e..07d6aa0b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,7 @@ "test:verbose": "yarn test --reporter verbose", "test:watch": "vitest --config vitest.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts", + "test:e2e": "vitest run --config vitest.config.e2e.ts", "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" }, "dependencies": { @@ -49,6 +50,7 @@ "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/utils": "^11.9.0", + "@ocap/kernel-daemon": "workspace:^", "@types/node": "^22.13.1", "acorn": "^8.15.0", "chokidar": "^4.0.1", @@ -82,6 +84,7 @@ "eslint-plugin-n": "^17.17.0", "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-promise": "^7.2.1", + "execa": "^9.5.2", "jsdom": "^27.4.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 0e8226292..75bdf53a7 100755 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -6,6 +6,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import { bundleSource } from './commands/bundle.ts'; +import { registerKernelCommands } from './commands/kernel/index.ts'; import { getServer } from './commands/serve.ts'; import { watchDir } from './commands/watch.ts'; import { defaultConfig } from './config.ts'; @@ -175,4 +176,6 @@ const yargsInstance = yargs(hideBin(process.argv)) }, ); +registerKernelCommands(yargsInstance, logger); + await yargsInstance.help('help').parse(); diff --git a/packages/cli/src/commands/kernel/daemon-process.ts b/packages/cli/src/commands/kernel/daemon-process.ts new file mode 100644 index 000000000..4da0cc87a --- /dev/null +++ b/packages/cli/src/commands/kernel/daemon-process.ts @@ -0,0 +1,153 @@ +/** + * Daemon process entry point. + * This file is forked as a detached child process by `startDaemon`. + * It creates a kernel, starts the RPC server, and writes the PID file. + */ +import '@metamask/kernel-shims/endoify-node'; + +// These packages are used at runtime in the daemon process but cannot be +// listed as direct dependencies of @ocap/cli due to Turbo cyclic dependency +// constraints (they depend on @metamask/ocap-kernel). +/* eslint-disable import-x/no-extraneous-dependencies */ +import { rpcHandlers } from '@metamask/kernel-browser-runtime'; +import { RpcService } from '@metamask/kernel-rpc-methods'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { Logger } from '@metamask/logger'; +import { Kernel } from '@metamask/ocap-kernel'; +import { + DB_FILE, + PID_FILE, + SOCK_FILE, + LOG_FILE, + createDaemonServer, +} from '@ocap/kernel-daemon'; +import { NodejsPlatformServices } from '@ocap/nodejs'; +/* eslint-enable import-x/no-extraneous-dependencies */ +import { appendFile, writeFile, access, unlink } from 'node:fs/promises'; +import type { Server } from 'node:net'; + +const logger = new Logger('kernel-daemon'); + +let server: Server | undefined; +let kernel: Kernel | undefined; + +/** + * Redirect logger output to log file. + * + * @param message - The message to log. + * @param args - Additional arguments. + */ +async function logToFile(message: string, ...args: unknown[]): Promise { + const timestamp = new Date().toISOString(); + const line = `[${timestamp}] ${message} ${args.map(String).join(' ')}\n`; + await appendFile(LOG_FILE, line); +} + +/** + * Check whether a file exists at the given path. + * + * @param filePath - The path to check. + * @returns True if the file exists. + */ +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Perform graceful shutdown: stop kernel, close server, clean up files. + */ +async function shutdown(): Promise { + await logToFile('Shutting down daemon...'); + + if (kernel) { + try { + await kernel.stop(); + } catch (error) { + await logToFile('Error stopping kernel:', String(error)); + } + } + + if (server) { + server.close(); + } + + if (await fileExists(PID_FILE)) { + await unlink(PID_FILE); + } + if (await fileExists(SOCK_FILE)) { + await unlink(SOCK_FILE); + } + + // eslint-disable-next-line n/no-process-exit + process.exit(0); +} + +/** + * + */ +async function main(): Promise { + await logToFile('Starting daemon process...'); + + // Write PID file + await writeFile(PID_FILE, String(process.pid)); + await logToFile(`PID ${process.pid} written to ${PID_FILE}`); + + // Create platform services + const platformServices = new NodejsPlatformServices({ + logger: logger.subLogger({ tags: ['platform-services'] }), + }); + + // Create kernel database with persistent storage + const kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: DB_FILE, + }); + + // Create kernel + kernel = await Kernel.make(platformServices, kernelDatabase, { + logger: logger.subLogger({ tags: ['kernel'] }), + }); + + await logToFile('Kernel created successfully'); + + // Initialize kernel identity (peer ID, crypto) for OCAP URL operations + await kernel.initIdentity(); + await logToFile('Kernel identity initialized'); + + // Build the RPC dispatcher from the standard kernel handlers + const rpcService = new RpcService(rpcHandlers, { + kernel, + executeDBQuery: (sql: string) => kernelDatabase.executeQuery(sql), + }); + + // Start RPC server + server = createDaemonServer({ + rpcDispatcher: rpcService, + logger: logger.subLogger({ tags: ['rpc-server'] }), + onShutdown: shutdown, + }); + + await logToFile('Daemon server started'); + + // Register signal handlers for graceful shutdown + process.on('SIGINT', () => { + shutdown().catch(async (error) => + logToFile('Error during shutdown:', String(error)), + ); + }); + process.on('SIGTERM', () => { + shutdown().catch(async (error) => + logToFile('Error during shutdown:', String(error)), + ); + }); +} + +main().catch(async (error) => { + await logToFile('Fatal error starting daemon:', String(error)); + // eslint-disable-next-line n/no-process-exit + process.exit(1); +}); diff --git a/packages/cli/src/commands/kernel/index.ts b/packages/cli/src/commands/kernel/index.ts new file mode 100644 index 000000000..8064a5958 --- /dev/null +++ b/packages/cli/src/commands/kernel/index.ts @@ -0,0 +1,89 @@ +import { Logger, makeTaglessConsoleTransport } from '@metamask/logger'; +import { makeFileTransport } from '@metamask/logger/file-transport'; +import { handleDaemonStart, registerDaemonCommands } from '@ocap/kernel-daemon'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Argv } from 'yargs'; + +const DAEMON_LOG_FILE = join(homedir(), '.ocap-kernel-daemon', 'daemon.log'); + +/** + * Create a logger for daemon CLI commands that writes to console (without tags) + * and to the daemon log file (with tags). + * + * @returns A Logger configured with console and file transports. + */ +function makeDaemonLogger(): Logger { + return new Logger({ + tags: ['daemon'], + transports: [ + makeTaglessConsoleTransport(), + makeFileTransport(DAEMON_LOG_FILE), + ], + }); +} + +/** + * Register the `kernel` command group on the given yargs instance. + * + * @param yargs - The yargs instance to extend. + * @param _logger - Logger for command output. + * @returns The extended yargs instance. + */ +export function registerKernelCommands(yargs: Argv, _logger: Logger): Argv { + return yargs.command( + 'kernel [command]', + 'Manage the ocap kernel daemon', + (_yargs) => + _yargs.showHelpOnFail(false).command( + 'daemon [command]', + 'Manage the background kernel daemon', + (yg) => { + const daemonLogger = makeDaemonLogger(); + const daemonProcessPath = fileURLToPath( + new URL('./daemon-process.mjs', import.meta.url), + ); + const getMethodSpecs = async (): Promise< + Record + > => { + // eslint-disable-next-line import-x/no-extraneous-dependencies + const { rpcMethodSpecs } = await import( + '@metamask/kernel-browser-runtime/rpc-handlers' + ); + return rpcMethodSpecs; + }; + return registerDaemonCommands(yg, { + logger: daemonLogger, + getMethodSpecs, + daemonProcessPath, + }); + }, + async (args) => { + if (!args.command) { + const daemonLogger = makeDaemonLogger(); + const daemonProcessPath = fileURLToPath( + new URL('./daemon-process.mjs', import.meta.url), + ); + try { + await handleDaemonStart(daemonProcessPath, daemonLogger); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith('Daemon already running') + ) { + daemonLogger.info(error.message); + } else { + throw error; + } + } + // eslint-disable-next-line n/no-process-exit + process.exit(0); + } + }, + ), + (_args) => { + // no-op: bare `kernel` shows help via demandCommand + }, + ); +} diff --git a/packages/cli/test/e2e/daemon.test.ts b/packages/cli/test/e2e/daemon.test.ts new file mode 100644 index 000000000..6748b0ad4 --- /dev/null +++ b/packages/cli/test/e2e/daemon.test.ts @@ -0,0 +1,154 @@ +import { execaNode } from 'execa'; +import path from 'node:path'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const CLI_DIR = path.resolve(import.meta.dirname, '..', '..'); +const APP = path.join(CLI_DIR, 'dist', 'app.mjs'); +const REPO_ROOT = path.resolve(CLI_DIR, '..', '..'); +const BUNDLE_PATH = path.resolve( + REPO_ROOT, + 'packages/kernel-test/src/vats/discoverable-capability-vat.bundle', +); + +async function ocap(...args: string[]) { + return execaNode(APP, args, { + cwd: CLI_DIR, + timeout: 15_000, + reject: false, + }); +} + +describe('daemon e2e', () => { + let bootstrapRootKref: string; + let calculatorKref: string; + + beforeAll(async () => { + // Ensure daemon is stopped and store is flushed for a clean slate + await ocap('kernel', 'daemon', 'stop'); + await ocap('kernel', 'daemon', 'flush'); + }); + + afterAll(async () => { + // Best-effort cleanup: stop daemon and flush store + await ocap('kernel', 'daemon', 'stop'); + await ocap('kernel', 'daemon', 'flush'); + }); + + it('starts the daemon', async () => { + const result = await ocap('kernel', 'daemon', 'start'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Daemon started'); + }); + + it('reports daemon status', async () => { + const result = await ocap('kernel', 'daemon', 'status'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Status: running'); + }); + + it('reports already running on second start', async () => { + const result = await ocap('kernel', 'daemon', 'start'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Daemon already running'); + }); + + it('restarts the daemon', async () => { + const result = await ocap('kernel', 'daemon', 'restart'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Daemon stopped'); + expect(result.stdout).toContain('Daemon started'); + }); + + it('launches discoverable-capability-vat bundle', async () => { + const result = await ocap('kernel', 'daemon', 'launch', BUNDLE_PATH); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Subcluster launched'); + expect(result.stdout).toContain('Bootstrap root kref:'); + + const krefMatch = result.stdout.match(/Bootstrap root kref: (ko\d+)/u); + expect(krefMatch).not.toBeNull(); + bootstrapRootKref = krefMatch![1]!; + }); + + it('invokes getCalculator on root', async () => { + const result = await ocap( + 'kernel', + 'daemon', + 'invoke', + bootstrapRootKref, + 'getCalculator', + ); + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout) as { + body: string; + slots: string[]; + }; + expect(parsed.slots).toBeDefined(); + expect(parsed.slots.length).toBeGreaterThan(0); + calculatorKref = parsed.slots[0]!; + }); + + it('inspects the calculator object', async () => { + const result = await ocap('kernel', 'daemon', 'inspect', calculatorKref); + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout) as { + methodNames: string[]; + }; + expect(parsed.methodNames).toContain('add'); + expect(parsed.methodNames).toContain('multiply'); + expect(parsed.methodNames).toContain('greet'); + }); + + it('invokes add on the calculator', async () => { + const result = await ocap( + 'kernel', + 'daemon', + 'invoke', + calculatorKref, + 'add', + '2', + '3', + ); + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout) as { + body: string; + slots: string[]; + }; + // Capdata body is smallcaps-encoded: "#5" + expect(JSON.parse(parsed.body.slice(1))).toBe(5); + }); + + it('invokes multiply on the calculator', async () => { + const result = await ocap( + 'kernel', + 'daemon', + 'invoke', + calculatorKref, + 'multiply', + '4', + '5', + ); + expect(result.exitCode).toBe(0); + + const parsed = JSON.parse(result.stdout) as { + body: string; + slots: string[]; + }; + expect(JSON.parse(parsed.body.slice(1))).toBe(20); + }); + + it('stops the daemon', async () => { + const result = await ocap('kernel', 'daemon', 'stop'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Daemon stopped'); + }); + + it('reports stopped status', async () => { + const result = await ocap('kernel', 'daemon', 'status'); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Status: stopped'); + }); +}); diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json index 5982e62d4..807ce2ec4 100644 --- a/packages/cli/tsconfig.build.json +++ b/packages/cli/tsconfig.build.json @@ -6,11 +6,13 @@ "outDir": "./dist", "emitDeclarationOnly": false, "rootDir": "./src", - "types": ["ses", "node"] + "types": ["ses", "node"], + "paths": {} }, "references": [ { "path": "../logger/tsconfig.build.json" }, - { "path": "../kernel-utils/tsconfig.build.json" } + { "path": "../kernel-utils/tsconfig.build.json" }, + { "path": "../kernel-daemon/tsconfig.build.json" } ], "files": [], "include": ["./src"] diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index d8181a0b9..5f15f5ee7 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -9,13 +9,15 @@ "references": [ { "path": "../logger" }, { "path": "../repo-tools" }, - { "path": "../kernel-utils" } + { "path": "../kernel-utils" }, + { "path": "../kernel-daemon" } ], "include": [ "../../vitest.config.ts", "./src/**/*.ts", "./test", "./vitest.config.ts", + "./vitest.config.e2e.ts", "./vitest.integration.config.ts" ] } diff --git a/packages/cli/vitest.config.e2e.ts b/packages/cli/vitest.config.e2e.ts new file mode 100644 index 000000000..bf2fafa79 --- /dev/null +++ b/packages/cli/vitest.config.e2e.ts @@ -0,0 +1,21 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'cli:e2e', + pool: 'forks', + include: ['./test/e2e/**/*.test.ts'], + exclude: ['./src/**/*'], + hookTimeout: 30_000, + testTimeout: 60_000, + }, + }), + ); +}); diff --git a/yarn.lock b/yarn.lock index 9c7de43f2..1a59957af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3375,6 +3375,7 @@ __metadata: "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/utils": "npm:^11.9.0" + "@ocap/kernel-daemon": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" @@ -3396,6 +3397,7 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" + execa: "npm:^9.5.2" glob: "npm:^11.0.0" jsdom: "npm:^27.4.0" libp2p: "npm:2.10.0" @@ -3601,7 +3603,7 @@ __metadata: languageName: unknown linkType: soft -"@ocap/kernel-daemon@workspace:packages/kernel-daemon": +"@ocap/kernel-daemon@workspace:^, @ocap/kernel-daemon@workspace:packages/kernel-daemon": version: 0.0.0-use.local resolution: "@ocap/kernel-daemon@workspace:packages/kernel-daemon" dependencies: