Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -175,4 +176,6 @@ const yargsInstance = yargs(hideBin(process.argv))
},
);

registerKernelCommands(yargsInstance, logger);

await yargsInstance.help('help').parse();
153 changes: 153 additions & 0 deletions packages/cli/src/commands/kernel/daemon-process.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}

/**
* Perform graceful shutdown: stop kernel, close server, clean up files.
*/
async function shutdown(): Promise<void> {
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<void> {
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);
});
89 changes: 89 additions & 0 deletions packages/cli/src/commands/kernel/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, { method: string }>
> => {
// 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
},
);
}
Loading
Loading