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 packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
},
"type": "module",
"bin": {
"ocap": "./dist/app.mjs"
"ocap": "./dist/app.mjs",
"ok": "./dist/ok.mjs"
},
"files": [
"dist/"
Expand Down
212 changes: 212 additions & 0 deletions packages/cli/src/commands/daemon-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { createConnection } from 'node:net';
import type { Socket } from 'node:net';
import { homedir } from 'node:os';
import { join } from 'node:path';

const READ_TIMEOUT_MS = 30_000;

/**
* Get the default daemon socket path.
*
* @returns The socket path.
*/
export function getSocketPath(): string {
return join(homedir(), '.ocap', 'console.sock');
}

/**
* Connect to a UNIX domain socket.
*
* @param socketPath - The socket path to connect to.
* @returns A connected socket.
*/
async function connectSocket(socketPath: string): Promise<Socket> {
return new Promise((resolve, reject) => {
const socket = createConnection(socketPath, () => {
socket.removeListener('error', reject);
resolve(socket);
});
socket.on('error', reject);
});
}

/**
* Read a single newline-delimited line from a socket.
*
* @param socket - The socket to read from.
* @returns The line read.
*/
async function readLine(socket: Socket): Promise<string> {
return new Promise((resolve, reject) => {
let buffer = '';

const timer = setTimeout(() => {
cleanup();
reject(new Error('Daemon response timed out'));
}, READ_TIMEOUT_MS);

/**
* Remove all listeners and clear the timeout.
*/
function cleanup(): void {
clearTimeout(timer);
socket.removeAllListeners('data');
socket.removeAllListeners('error');
socket.removeAllListeners('end');
socket.removeAllListeners('close');
}

const onData = (data: Buffer): void => {
buffer += data.toString();
const idx = buffer.indexOf('\n');
if (idx !== -1) {
cleanup();
resolve(buffer.slice(0, idx));
}
};

socket.on('data', onData);
socket.once('error', (error) => {
cleanup();
reject(error);
});
socket.once('end', () => {
cleanup();
reject(new Error('Socket closed before response received'));
});
socket.once('close', () => {
cleanup();
reject(new Error('Socket closed before response received'));
});
});
}

/**
* Write a newline-delimited line to a socket.
*
* @param socket - The socket to write to.
* @param line - The line to write.
*/
async function writeLine(socket: Socket, line: string): Promise<void> {
return new Promise((resolve, reject) => {
socket.write(`${line}\n`, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}

/**
* The response shape from the system console vat.
*/
export type ConsoleResponse = {
ok: boolean;
result?: unknown;
error?: string;
};

/**
* Send a JSON request to the daemon over a UNIX socket and return the response.
*
* Opens a connection, writes one JSON line, reads one JSON response line,
* then closes the connection. Retries once after a short delay if the
* connection is rejected (e.g. due to a probe connection race).
*
* @param socketPath - The UNIX socket path.
* @param request - The request to send.
* @param request.ref - Optional ref targeting a capability.
* @param request.method - The method name to invoke.
* @param request.args - Optional arguments array.
* @returns The parsed response.
*/
export async function sendCommand(
socketPath: string,
request: { ref?: string; method: string; args?: unknown[] },
): Promise<ConsoleResponse> {
const attempt = async (): Promise<ConsoleResponse> => {
const socket = await connectSocket(socketPath);
try {
await writeLine(socket, JSON.stringify(request));
const responseLine = await readLine(socket);
return JSON.parse(responseLine) as ConsoleResponse;
} finally {
socket.destroy();
}
};

try {
return await attempt();
} catch {
// Retry once after a short delay — the daemon's socket channel may
// still be cleaning up a previous probe connection.
await new Promise((resolve) => setTimeout(resolve, 100));
return attempt();
}
}

/**
* Check whether the daemon is running by probing the socket.
*
* @param socketPath - The UNIX socket path.
* @returns True if the daemon socket accepts a connection.
*/
export async function isDaemonRunning(socketPath: string): Promise<boolean> {
try {
const socket = await connectSocket(socketPath);
socket.destroy();
return true;
} catch {
return false;
}
}

/**
* Read all content from stdin, stripping shebang lines.
*
* @returns The stdin content with shebang lines removed.
*/
export async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
process.stdin.on('end', () => {
const raw = Buffer.concat(chunks).toString().trim();
const lines = raw.split('\n').filter((line) => !line.startsWith('#!'));
resolve(lines.join('\n').trim());
});
process.stdin.on('error', reject);
});
}

/**
* Read a ref from stdin. Strips shebang lines.
*
* @returns The ref string.
*/
export async function readRefFromStdin(): Promise<string> {
const content = await readStdin();
if (!content) {
throw new Error('No ref found in stdin');
}
return content;
}

/**
* Read a ref from a .ocap file. Strips shebang lines.
*
* @param filePath - The path to the .ocap file.
* @returns The ref string.
*/
export async function readRefFromFile(filePath: string): Promise<string> {
const { readFile } = await import('node:fs/promises');
const raw = (await readFile(filePath, 'utf-8')).trim();
const lines = raw.split('\n').filter((line) => !line.startsWith('#!'));
const ref = lines.join('\n').trim();
if (!ref) {
throw new Error(`No ref found in ${filePath}`);
}
return ref;
}
112 changes: 112 additions & 0 deletions packages/cli/src/commands/daemon-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/* eslint-disable n/no-process-exit, n/no-process-env, n/no-sync */
import '@metamask/kernel-shims/endoify-node';
import { Logger } from '@metamask/logger';
import type { LogEntry } from '@metamask/logger';
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

import { bundleFile } from './bundle.ts';

/**
* Create a file transport that writes logs to a file.
*
* @param logPath - The log file path.
* @returns A log transport function.
*/
function makeFileTransport(logPath: string) {
// eslint-disable-next-line @typescript-eslint/no-require-imports, n/global-require -- need sync fs for log transport
const { appendFileSync } = require('node:fs') as typeof import('node:fs');
return (entry: LogEntry): void => {
const line = `[${new Date().toISOString()}] [${entry.level}] ${entry.message ?? ''} ${(entry.data ?? []).map(String).join(' ')}\n`;
appendFileSync(logPath, line);
};
}

/**
* Main daemon entry point. Starts the daemon process and keeps it running.
*/
async function main(): Promise<void> {
const ocapDir = join(homedir(), '.ocap');
await mkdir(ocapDir, { recursive: true });

const logPath = join(ocapDir, 'daemon.log');
const logger = new Logger({
tags: ['daemon'],
transports: [makeFileTransport(logPath)],
});

try {
const socketPath =
process.env.OCAP_SOCKET_PATH ?? join(ocapDir, 'console.sock');
const consoleName = process.env.OCAP_CONSOLE_NAME ?? 'system-console';

// Bundle system console vat if needed
const bundlesDir = join(ocapDir, 'bundles');
await mkdir(bundlesDir, { recursive: true });

const bundlePath = join(bundlesDir, 'system-console-vat.bundle');
const cjsRequire = createRequire(import.meta.url);
const kernelPkgPath = cjsRequire.resolve(
'@metamask/ocap-kernel/package.json',
);
const vatSource = resolve(
dirname(kernelPkgPath),
'src/vats/system-console-vat.ts',
);
logger.info(`Bundling system console vat from ${vatSource}...`);
await bundleFile(vatSource, { logger, targetPath: bundlePath });
const bundleSpec = pathToFileURL(bundlePath).href;

// Dynamically import to avoid pulling @ocap/nodejs into the CLI bundle graph
// eslint-disable-next-line import-x/no-extraneous-dependencies -- workspace package
const { startDaemon } = await import('@ocap/nodejs');

const handle = await startDaemon({
systemConsoleBundleSpec: bundleSpec,
systemConsoleName: consoleName,
socketPath,
resetStorage: true,
logger,
});

// Write the admin .ocap file so `ok <path> <command>` works
const ocapPath =
process.env.OCAP_CONSOLE_PATH ?? join(ocapDir, `${consoleName}.ocap`);
await mkdir(dirname(ocapPath), { recursive: true });
await writeFile(ocapPath, `#!/usr/bin/env ok\n${handle.selfRef}\n`);
await chmod(ocapPath, 0o700);
logger.info(`Wrote ${ocapPath}`);

// Write PID file so `ok daemon stop` can signal this process
const pidPath = join(ocapDir, 'daemon.pid');
await writeFile(pidPath, String(process.pid));

logger.info(`Daemon started. Socket: ${handle.socketPath}`);

// Keep the process alive
const shutdown = async (signal: string): Promise<void> => {
logger.info(`Received ${signal}, shutting down...`);
await handle.close();
await rm(pidPath, { force: true });
process.exit(0);
};

process.on('SIGTERM', () => {
shutdown('SIGTERM').catch(() => process.exit(1));
});
process.on('SIGINT', () => {
shutdown('SIGINT').catch(() => process.exit(1));
});
} catch (error) {
logger.error('Daemon startup failed:', error);
process.exit(1);
}
}

main().catch((error) => {
process.stderr.write(`Daemon fatal: ${String(error)}\n`);
process.exit(1);
});
48 changes: 48 additions & 0 deletions packages/cli/src/commands/daemon-spawn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { isDaemonRunning } from './daemon-client.ts';

const POLL_INTERVAL_MS = 100;
const MAX_POLLS = 300; // 30 seconds

/**
* Ensure the daemon is running. If it is not, spawn it as a detached process
* and wait until the socket becomes responsive.
*
* @param socketPath - The UNIX socket path.
*/
export async function ensureDaemon(socketPath: string): Promise<void> {
if (await isDaemonRunning(socketPath)) {
return;
}

process.stderr.write('Starting daemon...\n');

const currentDir = dirname(fileURLToPath(import.meta.url));
const entryPath = join(currentDir, 'daemon-entry.mjs');

const child = spawn(process.execPath, [entryPath], {
detached: true,
stdio: 'ignore',
env: {
...process.env, // eslint-disable-line n/no-process-env -- pass env to child
OCAP_SOCKET_PATH: socketPath,
},
});
child.unref();

// Poll until daemon responds
for (let i = 0; i < MAX_POLLS; i++) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
if (await isDaemonRunning(socketPath)) {
process.stderr.write('Daemon ready.\n');
return;
}
}

throw new Error(
`Daemon did not start within ${(MAX_POLLS * POLL_INTERVAL_MS) / 1000}s`,
);
}
Loading
Loading