Skip to content

Commit bb268c4

Browse files
grypezclaude
andcommitted
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 <noreply@anthropic.com>
1 parent a270541 commit bb268c4

23 files changed

+1255
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import type { Mock } from 'vitest';
3+
4+
import { handleDaemonFlush } from './flush.ts';
5+
6+
vi.mock('../daemon-lifecycle.ts', () => ({
7+
flushDaemonStore: vi.fn(),
8+
}));
9+
10+
const makeLogger = () =>
11+
({
12+
info: vi.fn(),
13+
warn: vi.fn(),
14+
error: vi.fn(),
15+
debug: vi.fn(),
16+
}) as never;
17+
18+
describe('daemon-flush', () => {
19+
let flushDaemonStore: Mock;
20+
21+
beforeEach(async () => {
22+
vi.clearAllMocks();
23+
const lifecycle = await import('../daemon-lifecycle.ts');
24+
flushDaemonStore = lifecycle.flushDaemonStore as Mock;
25+
});
26+
27+
it('delegates to flushDaemonStore', async () => {
28+
flushDaemonStore.mockResolvedValue(undefined);
29+
const logger = makeLogger();
30+
31+
await handleDaemonFlush(logger);
32+
33+
expect(flushDaemonStore).toHaveBeenCalledWith(logger);
34+
});
35+
36+
it('propagates errors', async () => {
37+
flushDaemonStore.mockRejectedValue(
38+
new Error('Cannot flush while daemon is running'),
39+
);
40+
41+
await expect(handleDaemonFlush(makeLogger())).rejects.toThrow(
42+
'Cannot flush while daemon is running',
43+
);
44+
});
45+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { Logger } from '@metamask/logger';
2+
3+
import { flushDaemonStore } from '../daemon-lifecycle.ts';
4+
5+
/**
6+
* Handle the `kernel daemon flush` command.
7+
* Deletes the daemon database (daemon must be stopped).
8+
*
9+
* @param logger - Logger for output.
10+
*/
11+
export async function handleDaemonFlush(logger: Logger): Promise<void> {
12+
await flushDaemonStore(logger);
13+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import type { Argv } from 'yargs';
2+
3+
import { handleDaemonFlush } from './flush.ts';
4+
import { handleInspect } from './inspect.ts';
5+
import { handleInvoke } from './invoke.ts';
6+
import { handleLaunch } from './launch.ts';
7+
import { handleDaemonLogs } from './logs.ts';
8+
import { handleDaemonPid } from './pid.ts';
9+
import { handleDaemonRestart } from './restart.ts';
10+
import { handleDaemonStart } from './start.ts';
11+
import { handleDaemonStatus } from './status.ts';
12+
import { handleDaemonStop } from './stop.ts';
13+
import type { DaemonCommandsConfig } from './types.ts';
14+
import { handleUrlIssue } from './url-issue.ts';
15+
import { handleUrlRedeem } from './url-redeem.ts';
16+
import { handleView } from './view.ts';
17+
18+
/**
19+
* Run a daemon command handler and exit the process on completion.
20+
* Errors propagate to yargs' fail handler; successful completion exits with 0.
21+
*
22+
* @param fn - The async handler to run.
23+
* @returns A promise that exits the process on success.
24+
*/
25+
async function runAndExit(fn: () => Promise<void>): Promise<void> {
26+
await fn();
27+
// eslint-disable-next-line n/no-process-exit
28+
process.exit(0);
29+
}
30+
31+
/**
32+
* Register all daemon subcommands on the given yargs instance.
33+
* Captures config in closure so individual handlers receive injected dependencies.
34+
* Every handler exits the process with code 0 after completing successfully.
35+
*
36+
* @param yargs - The yargs instance to extend (the `daemon` subcommand builder).
37+
* @param config - Injected configuration (logger, getMethodSpecs, daemonProcessPath).
38+
* @returns The extended yargs instance.
39+
*/
40+
export function registerDaemonCommands(
41+
yargs: Argv,
42+
config: DaemonCommandsConfig,
43+
): Argv {
44+
const { logger, getMethodSpecs, daemonProcessPath } = config;
45+
46+
return yargs
47+
.command(
48+
'start',
49+
'Start the background kernel daemon',
50+
(yg) => yg,
51+
async () =>
52+
runAndExit(async () => {
53+
try {
54+
await handleDaemonStart(daemonProcessPath, logger);
55+
} catch (error) {
56+
if (
57+
error instanceof Error &&
58+
error.message.startsWith('Daemon already running')
59+
) {
60+
logger.info(error.message);
61+
} else {
62+
throw error;
63+
}
64+
}
65+
}),
66+
)
67+
.command(
68+
'stop',
69+
'Stop the background kernel daemon',
70+
(yg) => yg,
71+
async () => runAndExit(async () => handleDaemonStop(logger)),
72+
)
73+
.command(
74+
'status',
75+
'Show daemon status',
76+
(yg) => yg,
77+
async () => runAndExit(async () => handleDaemonStatus(logger)),
78+
)
79+
.command(
80+
'restart',
81+
'Restart the background kernel daemon',
82+
(yg) =>
83+
yg.option('flush', {
84+
type: 'boolean',
85+
default: false,
86+
describe: 'Flush the daemon database before restarting',
87+
}),
88+
async (args) =>
89+
runAndExit(async () =>
90+
handleDaemonRestart(daemonProcessPath, logger, {
91+
flush: args.flush,
92+
}),
93+
),
94+
)
95+
.command(
96+
'flush',
97+
'Delete the daemon database (daemon must be stopped)',
98+
(yg) => yg,
99+
async () => runAndExit(async () => handleDaemonFlush(logger)),
100+
)
101+
.command(
102+
'pid',
103+
'Print the daemon process ID',
104+
(yg) => yg,
105+
async () => runAndExit(async () => handleDaemonPid(logger)),
106+
)
107+
.command(
108+
'logs',
109+
'Print the daemon log file',
110+
(yg) => yg,
111+
async () => runAndExit(async () => handleDaemonLogs(logger)),
112+
)
113+
.command(
114+
'launch <path>',
115+
'Launch a .bundle or subcluster.json via the daemon',
116+
(yg) =>
117+
yg.positional('path', {
118+
type: 'string',
119+
demandOption: true,
120+
describe: 'Path to a .bundle or subcluster.json file',
121+
}),
122+
async (args) =>
123+
runAndExit(async () => handleLaunch(args.path, getMethodSpecs, logger)),
124+
)
125+
.command(
126+
'view',
127+
'View kernel state as JSON',
128+
(yg) => yg,
129+
async () => runAndExit(async () => handleView(getMethodSpecs, logger)),
130+
)
131+
.command(
132+
'invoke <kref> <method> [args..]',
133+
'Invoke a method on a kernel object via the daemon',
134+
(yg) =>
135+
yg
136+
.positional('kref', {
137+
type: 'string',
138+
demandOption: true,
139+
describe: 'The kernel reference (e.g. ko1)',
140+
})
141+
.positional('method', {
142+
type: 'string',
143+
demandOption: true,
144+
describe: 'The method name to invoke',
145+
})
146+
.positional('args', {
147+
type: 'string',
148+
array: true,
149+
default: [] as string[],
150+
describe: 'Arguments to pass (JSON-parsed if possible)',
151+
}),
152+
async (args) =>
153+
runAndExit(async () =>
154+
handleInvoke(
155+
args.kref,
156+
args.method,
157+
args.args ?? [],
158+
getMethodSpecs,
159+
logger,
160+
),
161+
),
162+
)
163+
.command(
164+
'inspect <kref>',
165+
'Inspect a kernel object (methods, guard, schema)',
166+
(yg) =>
167+
yg.positional('kref', {
168+
type: 'string',
169+
demandOption: true,
170+
describe: 'The kernel reference (e.g. ko1)',
171+
}),
172+
async (args) =>
173+
runAndExit(async () =>
174+
handleInspect(args.kref, getMethodSpecs, logger),
175+
),
176+
)
177+
.command('url [command]', 'Issue and redeem OCAP URLs', (yg) =>
178+
yg
179+
.command(
180+
'issue <kref>',
181+
'Issue an OCAP URL for a kernel object',
182+
(yg2) =>
183+
yg2.positional('kref', {
184+
type: 'string',
185+
demandOption: true,
186+
describe: 'The kernel reference (e.g. ko1)',
187+
}),
188+
async (args) =>
189+
runAndExit(async () =>
190+
handleUrlIssue(args.kref, getMethodSpecs, logger),
191+
),
192+
)
193+
.command(
194+
'redeem <url>',
195+
'Redeem an OCAP URL to get its kernel reference',
196+
(yg2) =>
197+
yg2.positional('url', {
198+
type: 'string',
199+
demandOption: true,
200+
describe: 'The OCAP URL to redeem',
201+
}),
202+
async (args) =>
203+
runAndExit(async () =>
204+
handleUrlRedeem(args.url, getMethodSpecs, logger),
205+
),
206+
)
207+
.demandCommand(1, 'Specify a url subcommand: issue or redeem'),
208+
);
209+
}
210+
211+
export { handleDaemonStart } from './start.ts';
212+
export type { DaemonCommandsConfig } from './types.ts';

0 commit comments

Comments
 (0)