diff --git a/.changeset/brave-falcons-dance.md b/.changeset/brave-falcons-dance.md new file mode 100644 index 0000000..4f1723b --- /dev/null +++ b/.changeset/brave-falcons-dance.md @@ -0,0 +1,10 @@ +--- +"@offckb/cli": patch +--- + +Add daemon mode and structured JSON output for agent-friendly usage, plus a `node stop` command to terminate the daemon. + +- `offckb node --daemon` starts the CKB devnet as a detached background process and writes the PID and logs to the devnet data folder. +- `offckb --json ` emits structured JSON log output for programmatic consumption. +- `offckb node stop` reads the daemon PID file and gracefully shuts down the daemon, falling back to force-kill if necessary. It now verifies the target process identity, handles stale PID files, and cleans up on error paths. +- Hardened daemon lifecycle: duplicate daemon starts are rejected, CLI entry resolution supports packaged/npx environments via `OFFCKB_CLI_PATH`, and log/PID directory creation failures are handled gracefully. diff --git a/README.md b/README.md index d575e6d..aca9c15 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Options: Commands: node [CKB-Version] Use the CKB to start devnet + node stop Stop the running CKB devnet daemon create [options] [project-name] Create a new CKB Smart Contract project in JavaScript. deploy [options] Deploy contracts to different networks, only supports devnet and testnet debug [options] Quickly debug transaction with tx-hash @@ -118,6 +119,40 @@ offckb node --binary-path /path/to/your/ckb/binary When using `--binary-path`, it will ignore the specified version and network, and only work for devnet. +**Run in Daemon Mode** + +Start the devnet in the background so your terminal stays free: + +```sh +offckb node --daemon +``` + +The daemon writes its logs and PID to the devnet data folder, for example: + +- Logs: `~/Library/Application Support/offckb-nodejs/devnet/data/logs/daemon.log` +- PID file: `~/Library/Application Support/offckb-nodejs/devnet/data/logs/daemon.pid` + +Stop the daemon later with: + +```sh +offckb node stop +``` + +**Agent-Friendly JSON Output** + +For programmatic consumption or agent integration, add `--json` to any command to emit structured JSON logs: + +```sh +offckb node --json +offckb node --daemon --json +``` + +Each log line is a single JSON object: + +```json +{"level":"info","message":"Launching CKB devnet Node...","timestamp":"2026-07-07T07:10:00.000Z"} +``` + **RPC & Proxy RPC** When the Devnet starts: diff --git a/src/cli.ts b/src/cli.ts index c1ffa21..d4ffd03 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { Command } from 'commander'; -import { startNode } from './cmd/node'; +import { startNode, stopNode } from './cmd/node'; import { accounts } from './cmd/accounts'; import { clean } from './cmd/clean'; import { setUTF8EncodingForWindows } from './util/encoding'; @@ -28,7 +28,15 @@ setUTF8EncodingForWindows(); const program = new Command(); program.name('offckb').description(description).version(version).enablePositionalOptions(); -program +program.option('--json', 'Output logs in JSON format for agent/programmatic consumption'); +program.hook('preAction', (thisCommand) => { + const opts = thisCommand.opts(); + if (opts.json) { + logger.setJsonMode(true); + } +}); + +const nodeCommand = program .command('node [CKB-Version]') .description('Use the CKB to start devnet') .option('--network ', 'Specify the network to deploy to', 'devnet') @@ -36,10 +44,16 @@ program '-b, --binary-path ', 'Specify the CKB binary path to use, only for devnet, when set, will ignore version and network', ) - .action(async (version: string, options: { network: Network; binaryPath?: string }) => { - return startNode({ version, network: options.network, binaryPath: options.binaryPath }); + .option('--daemon', 'Run the node in the background as a daemon (devnet only)') + .action(async (version: string, options: { network: Network; binaryPath?: string; daemon?: boolean }) => { + return startNode({ version, network: options.network, binaryPath: options.binaryPath, daemon: options.daemon }); }); +nodeCommand + .command('stop') + .description('Stop the running CKB devnet daemon') + .action(async () => stopNode()); + program .command('create [project-name]') .description('Create a new CKB Smart Contract project in JavaScript.') diff --git a/src/cmd/node.ts b/src/cmd/node.ts index e0e67d1..0b5faf2 100644 --- a/src/cmd/node.ts +++ b/src/cmd/node.ts @@ -1,4 +1,6 @@ -import { exec } from 'child_process'; +import { exec, spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; import { initChainIfNeeded } from '../node/init-chain'; import { installCKBBinary } from '../node/install'; import { getCKBBinaryPath, readSettings } from '../cfg/setting'; @@ -11,16 +13,31 @@ export interface NodeProp { version?: string; network?: Network; binaryPath?: string; + daemon?: boolean; } -export function startNode({ version, network = Network.devnet, binaryPath }: NodeProp) { +interface PidMetadata { + pid: number; + scriptPath: string; + startedAt: string; +} + +const DAEMON_LOG_DIR = 'logs'; +const DAEMON_LOG_FILE = 'daemon.log'; +const DAEMON_PID_FILE = 'daemon.pid'; +const DAEMON_CHILD_ENV = 'OFFCKB_DAEMON_CHILD'; + +export function startNode({ version, network = Network.devnet, binaryPath, daemon }: NodeProp) { if (binaryPath && network !== Network.devnet) { logger.warn('Custom binaryPath is only supported for devnet. The provided binaryPath will be ignored.'); } + if (daemon && network !== Network.devnet) { + logger.warn('Daemon mode is only supported for devnet. The daemon flag will be ignored.'); + } switch (network) { case Network.devnet: - return nodeDevnet({ version, binaryPath }); + return nodeDevnet({ version, binaryPath, daemon }); case Network.testnet: return nodeTestnet(); case Network.mainnet: @@ -30,7 +47,11 @@ export function startNode({ version, network = Network.devnet, binaryPath }: Nod } } -export async function nodeDevnet({ version, binaryPath }: NodeProp) { +export async function nodeDevnet({ version, binaryPath, daemon }: NodeProp) { + if (daemon) { + return startDaemon(); + } + const settings = readSettings(); const ckbVersion = version || settings.bins.defaultCKBVersion; let ckbBinPath = ''; @@ -86,6 +107,339 @@ export async function nodeDevnet({ version, binaryPath }: NodeProp) { } } +function resolveDaemonPaths() { + const settings = readSettings(); + const logDir = path.join(settings.devnet.dataPath, DAEMON_LOG_DIR); + const logFile = path.join(logDir, DAEMON_LOG_FILE); + const pidFile = path.join(logDir, DAEMON_PID_FILE); + return { logDir, logFile, pidFile }; +} + +function readPidFile(pidFile: string): PidMetadata | null { + let raw: string; + try { + raw = fs.readFileSync(pidFile, 'utf8').trim(); + } catch (error) { + // Treat a missing or unreadable PID file as "no daemon". + return null; + } + + if (!raw) { + return null; + } + + // Backward compatibility: plain integer PID written by older versions. + const plainPid = Number(raw); + if (Number.isInteger(plainPid) && plainPid > 0) { + return { pid: plainPid, scriptPath: resolveCliEntry() ?? '', startedAt: new Date(0).toISOString() }; + } + + try { + const parsed = JSON.parse(raw) as Partial; + const pid = Number(parsed.pid); + if (Number.isInteger(pid) && pid > 0 && typeof parsed.scriptPath === 'string') { + return { + pid, + scriptPath: parsed.scriptPath, + startedAt: parsed.startedAt ?? new Date(0).toISOString(), + }; + } + } catch { + // fall through to sentinel below + } + + // Content exists but is neither a valid plain PID nor valid metadata. + // Return a sentinel so stopNode can report an invalid PID and clean up. + return { pid: NaN, scriptPath: '', startedAt: new Date(0).toISOString() }; +} + +function writePidFile(pidFile: string, metadata: PidMetadata) { + fs.writeFileSync(pidFile, JSON.stringify(metadata, null, 2)); +} + +function resolveCliEntry(): string | null { + // In priority order. process.argv[1] is the most reliable for a Node CLI. + // OFFCKB_CLI_PATH is an escape hatch for packaged/npx/weird environments. + // require.main?.filename is a final fallback when argv is unavailable. + const candidates = [process.env.OFFCKB_CLI_PATH, process.argv[1], require.main?.filename].filter( + (c): c is string => typeof c === 'string' && c.length > 0, + ); + + for (const candidate of candidates) { + try { + const resolved = path.resolve(candidate); + const stats = fs.statSync(resolved); + if (stats.isFile()) { + return resolved; + } + } catch { + // Candidate is missing or not a file; try the next one. + } + } + + return null; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function cleanupPidFile(pidFile: string) { + try { + fs.unlinkSync(pidFile); + } catch (error) { + logger.warn(`Failed to remove PID file ${pidFile}:`, error); + } +} + +function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + return new Promise((resolve) => { + const check = () => { + if (!isProcessAlive(pid)) { + resolve(true); + return; + } + if (Date.now() - start >= timeoutMs) { + resolve(false); + return; + } + setTimeout(check, 100); + }; + check(); + }); +} + +function getProcessCommandLine(pid: number): Promise { + return new Promise((resolve) => { + if (process.platform === 'win32') { + exec(`wmic process where ProcessId=${pid} get CommandLine /format:list`, (error, stdout) => { + if (error) { + resolve(null); + return; + } + const match = stdout.match(/CommandLine=(.+)/); + resolve(match ? match[1].trim() : null); + }); + } else { + exec(`ps -p ${pid} -o args=`, (error, stdout) => { + if (error) { + resolve(null); + return; + } + resolve(stdout.trim()); + }); + } + }); +} + +async function verifyDaemonIdentity(pid: number, metadata: PidMetadata): Promise { + const cmdline = await getProcessCommandLine(pid); + if (!cmdline) { + return false; + } + + // The daemon child re-runs the same CLI entry point, so its command line + // should reference the same script and should be a Node process. + const scriptName = path.basename(metadata.scriptPath); + const scriptDir = path.dirname(metadata.scriptPath); + const looksLikeNode = cmdline.includes('node') || cmdline.includes('nodejs'); + const looksLikeOurScript = + cmdline.includes(metadata.scriptPath) || (scriptName !== '' && cmdline.includes(scriptName)); + const looksLikeOffckb = cmdline.includes('offckb') || scriptDir.includes('offckb'); + + return looksLikeNode && (looksLikeOurScript || looksLikeOffckb); +} + +function terminateProcess(pid: number, signal: 'SIGTERM' | 'SIGKILL'): Promise { + return new Promise((resolve, reject) => { + if (process.platform === 'win32') { + // Windows has no POSIX signals and process.kill(pid) only terminates the + // single process. Use taskkill to terminate the whole tree. + // /T kills the process and all child processes. + // /F forces termination when SIGKILL is requested. + const args = signal === 'SIGKILL' ? ['/T', '/F', '/PID', String(pid)] : ['/T', '/PID', String(pid)]; + const taskkill = spawn('taskkill', args, { stdio: 'ignore' }); + taskkill.on('error', reject); + taskkill.on('exit', () => { + // taskkill may return non-zero if the process is already gone, which + // is acceptable for our purposes. + resolve(); + }); + return; + } + + // On POSIX, detached: true makes the child a session/process group leader. + // A negative pid sends the signal to the entire process group, ensuring + // the CKB node, miner and RPC proxy all receive it. + try { + process.kill(-pid, signal); + resolve(); + } catch (error) { + reject(error); + } + }); +} + +function startDaemon() { + const { logDir, logFile, pidFile } = resolveDaemonPaths(); + + // Prevent duplicate daemon starts. If a daemon is already running, refuse + // to overwrite its PID file. + const existing = readPidFile(pidFile); + if (existing && isProcessAlive(existing.pid)) { + logger.error(`A CKB devnet daemon is already running (PID ${existing.pid}). Stop it first with: offckb node stop`); + return; + } + if (existing && !isProcessAlive(existing.pid)) { + // Stale PID file from a crashed daemon; clean it up before starting anew. + cleanupPidFile(pidFile); + } + + let out: number | undefined; + let err: number | undefined; + try { + fs.mkdirSync(logDir, { recursive: true }); + out = fs.openSync(logFile, 'a'); + err = fs.openSync(logFile, 'a'); + } catch (error) { + logger.error(`Failed to prepare daemon log directory or log file at ${logFile}:`, error); + return; + } + + const scriptPath = resolveCliEntry(); + if (!scriptPath) { + logger.error('Unable to determine the CLI entry point for daemon mode. Set OFFCKB_CLI_PATH to the offckb script.'); + closeFileDescriptors(out, err); + return; + } + + const childArgs = process.argv.slice(2).filter((arg) => arg !== '--daemon'); + const childEnv = { ...process.env, [DAEMON_CHILD_ENV]: '1' }; + + let child; + try { + child = spawn(process.execPath, [scriptPath, ...childArgs], { + detached: true, + stdio: ['ignore', out, err], + env: childEnv, + }); + } catch (error) { + logger.error('Failed to spawn daemon process:', error); + closeFileDescriptors(out, err); + return; + } + + if (!child.pid) { + logger.error('Failed to spawn daemon process: no PID returned.'); + closeFileDescriptors(out, err); + return; + } + + child.unref(); + + child.on('error', (error) => { + logger.error('Daemon child process failed to start:', error); + cleanupPidFile(pidFile); + }); + + const metadata: PidMetadata = { + pid: child.pid, + scriptPath, + startedAt: new Date().toISOString(), + }; + writePidFile(pidFile, metadata); + + // File descriptors are now owned by the spawned child; close our copies. + closeFileDescriptors(out, err); + + logger.success(`CKB devnet daemon started with PID ${child.pid}.`); + logger.info(`Logs: ${logFile}`); + logger.info(`PID file: ${pidFile}`); + logger.info('Stop the daemon with: offckb node stop'); +} + +function closeFileDescriptors(...fds: (number | undefined)[]) { + for (const fd of fds) { + if (fd === undefined) continue; + try { + fs.closeSync(fd); + } catch { + // ignore + } + } +} + +export async function stopNode() { + const { pidFile } = resolveDaemonPaths(); + + const metadata = readPidFile(pidFile); + if (!metadata) { + logger.warn(`No daemon PID file found at ${pidFile}. Is the devnet daemon running?`); + return; + } + + const pid = metadata.pid; + if (!Number.isInteger(pid) || pid <= 0) { + logger.error(`Invalid PID in ${pidFile}: ${pid}`); + cleanupPidFile(pidFile); + return; + } + + if (!isProcessAlive(pid)) { + logger.warn(`Daemon process ${pid} is not running.`); + cleanupPidFile(pidFile); + return; + } + + const identityOk = await verifyDaemonIdentity(pid, metadata); + if (!identityOk) { + logger.error( + `Process ${pid} does not appear to be the offckb daemon. Refusing to send signals to avoid killing an unrelated process. ` + + `If you are sure this is the daemon, stop it manually and remove ${pidFile}.`, + ); + return; + } + + logger.info(`Stopping CKB devnet daemon (PID ${pid})...`); + try { + await terminateProcess(pid, 'SIGTERM'); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ESRCH') { + logger.warn(`Daemon process ${pid} is not running.`); + cleanupPidFile(pidFile); + return; + } + if (err.code === 'EPERM') { + logger.error(`Permission denied when sending SIGTERM to daemon process ${pid}.`); + } else { + logger.error(`Failed to send SIGTERM to daemon process ${pid}:`, error); + } + // Still try to clean up the PID file so the user can recover. + cleanupPidFile(pidFile); + return; + } + + const exited = await waitForProcessExit(pid, 5000); + if (!exited) { + logger.warn(`Daemon process ${pid} did not exit gracefully, sending SIGKILL...`); + try { + await terminateProcess(pid, 'SIGKILL'); + } catch (error) { + logger.error(`Failed to send SIGKILL to daemon process ${pid}:`, error); + } + } + + cleanupPidFile(pidFile); + logger.success('CKB devnet daemon stopped.'); +} + export async function nodeTestnet() { // todo: maybe we can actually start a node for testnet later // by default we start a proxy server for testnet diff --git a/src/util/logger.ts b/src/util/logger.ts index 1358486..05ec4b3 100644 --- a/src/util/logger.ts +++ b/src/util/logger.ts @@ -17,16 +17,20 @@ interface LoggerOptions { level?: LogLevel; enableColors?: boolean; showLevel?: boolean; + jsonMode?: boolean; + transports?: winston.transport[]; } class UnifiedLogger { private logger: winston.Logger; private enableColors: boolean; private showLevel: boolean; + private jsonMode: boolean; constructor(options: LoggerOptions = {}) { this.enableColors = options.enableColors !== false; this.showLevel = options.showLevel !== false; + this.jsonMode = options.jsonMode === true; // Create Winston logger with custom format and levels this.logger = winston.createLogger({ @@ -45,7 +49,7 @@ class UnifiedLogger { return this.formatMessage(level as LogLevel, message as string, timestamp as string); }), ), - transports: [ + transports: options.transports || [ new winston.transports.Console({ stderrLevels: ['error', 'warn'], }), @@ -53,10 +57,28 @@ class UnifiedLogger { }); } + /** + * Toggle JSON output mode. When enabled, every log line is emitted as a + * structured JSON object, which is easier for agents and scripts to parse. + */ + setJsonMode(enabled: boolean) { + this.jsonMode = enabled; + } + /** * Format the message with appropriate colors and structure */ - private formatMessage(level: LogLevel, message: string, _timestamp?: string): string { + private formatMessage(level: LogLevel, message: string, timestamp?: string): string { + // Agent-friendly JSON output: one JSON object per log line + if (this.jsonMode) { + const normalizedMessage = Array.isArray(message) ? message.join('\n') : String(message); + return JSON.stringify({ + level, + message: normalizedMessage, + timestamp, + }); + } + // If showLevel is false, return just the message if (!this.showLevel) { if (Array.isArray(message)) { @@ -148,6 +170,13 @@ class UnifiedLogger { * Log a message with the specified level */ private log(level: LogLevel, message: string | string[]) { + // In JSON mode, emit multi-line messages as a single structured log entry + // so agents can parse one complete JSON object per line. + if (this.jsonMode && Array.isArray(message)) { + this.logger.log(level, message.join('\n')); + return; + } + if (Array.isArray(message)) { message.forEach((line) => this.logger.log(level, line)); } else { diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..a8e5528 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,56 @@ +import winston from 'winston'; +import { UnifiedLogger } from '../src/util/logger'; + +interface WinstonInfo { + level: string; + message: unknown; + [Symbol.for('message')]?: string; +} + +class CapturingTransport extends winston.transports.Console { + logs: string[] = []; + + log(info: WinstonInfo, next: () => void) { + this.logs.push(info[Symbol.for('message')] ?? String(info.message)); + next(); + } +} + +function createCapturingTransport(): { transport: CapturingTransport; logs: string[] } { + const transport = new CapturingTransport(); + return { transport, logs: transport.logs }; +} + +describe('UnifiedLogger JSON mode', () => { + it('outputs plain text by default', () => { + const { transport, logs } = createCapturingTransport(); + const log = UnifiedLogger.create({ transports: [transport], showLevel: false }); + log.info('hello world'); + + expect(logs).toHaveLength(1); + expect(logs[0]).toBe('hello world'); + }); + + it('outputs structured JSON when jsonMode is enabled', () => { + const { transport, logs } = createCapturingTransport(); + const log = UnifiedLogger.create({ transports: [transport] }); + log.setJsonMode(true); + log.info('agent friendly'); + + expect(logs).toHaveLength(1); + const parsed = JSON.parse(logs[0]); + expect(parsed.level).toBe('info'); + expect(parsed.message).toBe('agent friendly'); + expect(parsed.timestamp).toBeDefined(); + }); + + it('joins array messages into a single string in JSON mode', () => { + const { transport, logs } = createCapturingTransport(); + const log = UnifiedLogger.create({ transports: [transport] }); + log.setJsonMode(true); + log.info(['line one', 'line two']); + + const parsed = JSON.parse(logs[0]); + expect(parsed.message).toBe('line one\nline two'); + }); +}); diff --git a/tests/node-command.test.ts b/tests/node-command.test.ts new file mode 100644 index 0000000..b5c99aa --- /dev/null +++ b/tests/node-command.test.ts @@ -0,0 +1,375 @@ +import { startNode, stopNode } from '../src/cmd/node'; +import { Network } from '../src/type/base'; +import * as path from 'path'; + +const mockSpawn = jest.fn(); +const mockExec = jest.fn(); +const mockOpenSync = jest.fn(); +const mockWriteFileSync = jest.fn(); +const mockMkdirSync = jest.fn(); +const mockReadFileSync = jest.fn(); +const mockExistsSync = jest.fn(); +const mockUnlinkSync = jest.fn(); +const mockStatSync = jest.fn(); +const mockCloseSync = jest.fn(); + +jest.mock('child_process', () => ({ + ...jest.requireActual('child_process'), + spawn: (...args: unknown[]) => mockSpawn(...args), + exec: (...args: unknown[]) => mockExec(...args), +})); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + openSync: (...args: unknown[]) => mockOpenSync(...args), + writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), + mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), + readFileSync: (...args: unknown[]) => mockReadFileSync(...args), + existsSync: (...args: unknown[]) => mockExistsSync(...args), + unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args), + statSync: (...args: unknown[]) => mockStatSync(...args), + closeSync: (...args: unknown[]) => mockCloseSync(...args), +})); + +jest.mock('../src/tools/rpc-proxy', () => ({ + createRPCProxy: jest.fn(() => ({ + start: jest.fn(), + stop: jest.fn(), + })), +})); + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + devnet: { + configPath: '/tmp/offckb-devnet-config', + dataPath: '/tmp/offckb-devnet-data', + rpcUrl: 'http://127.0.0.1:8114', + rpcProxyPort: 28114, + }, + testnet: { + rpcUrl: 'https://testnet.ckb.dev', + rpcProxyPort: 38114, + }, + mainnet: { + rpcUrl: 'https://mainnet.ckb.dev', + rpcProxyPort: 48114, + }, + }), +})); + +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + setJsonMode: jest.fn(), + }, +})); + +import { logger } from '../src/util/logger'; + +const dataPath = '/tmp/offckb-devnet-data'; +const logDir = path.join(dataPath, 'logs'); +const pidFile = path.join(logDir, 'daemon.pid'); +const logFile = path.join(logDir, 'daemon.log'); + +function mockDaemonCommandLine(scriptPath: string) { + mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { + if (cmd.startsWith('ps ')) { + callback(null, `/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + if (cmd.startsWith('wmic ')) { + // WMIC returns key/value pairs, e.g. "CommandLine=..." + callback(null, `CommandLine=/usr/bin/node ${scriptPath} node`); + return undefined as unknown as ReturnType; + } + callback(null, ''); + return undefined as unknown as ReturnType; + }); +} + +describe('node command daemon mode', () => { + const originalArgv = process.argv; + let killSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockReadFileSync.mockReset(); + process.argv = ['node', '/path/to/offckb', 'node', '--daemon']; + mockOpenSync.mockReturnValue(3); + mockStatSync.mockReturnValue({ isFile: () => true }); + mockSpawn.mockReturnValue({ + pid: 12345, + unref: jest.fn(), + on: jest.fn(), + }); + killSpy = jest.spyOn(process, 'kill').mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + // By default pretend the PID from a PID file is alive; individual tests override this. + return true; + } + return true; + }); + }); + + afterEach(() => { + process.argv = originalArgv; + killSpy.mockRestore(); + }); + + it('spawns a detached child process without the --daemon flag', () => { + startNode({ network: Network.devnet, daemon: true }); + + expect(mockMkdirSync).toHaveBeenCalledWith(logDir, { recursive: true }); + const resolvedScriptPath = path.resolve('/path/to/offckb'); + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + [resolvedScriptPath, 'node'], + expect.objectContaining({ + detached: true, + stdio: ['ignore', 3, 3], + env: expect.objectContaining({ OFFCKB_DAEMON_CHILD: '1' }), + }), + ); + + const writtenMetadata = JSON.parse(mockWriteFileSync.mock.calls[0][1]); + expect(writtenMetadata.pid).toBe(12345); + expect(writtenMetadata.scriptPath).toBe(resolvedScriptPath); + expect(writtenMetadata.startedAt).toBeDefined(); + + expect(logger.success).toHaveBeenCalledWith('CKB devnet daemon started with PID 12345.'); + }); + + it('warns and ignores daemon flag for non-devnet networks', () => { + startNode({ network: Network.testnet, daemon: true }); + + expect(logger.warn).toHaveBeenCalledWith( + 'Daemon mode is only supported for devnet. The daemon flag will be ignored.', + ); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('refuses to start when a daemon is already running', () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), + ); + + startNode({ network: Network.devnet, daemon: true }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('already running')); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('cleans up a stale PID file and starts a new daemon', () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ pid: 9999, scriptPath: '/path/to/offckb', startedAt: new Date().toISOString() }), + ); + killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + const err = new Error('ESRCH') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + } + return true; + }); + + startNode({ network: Network.devnet, daemon: true }); + + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(mockSpawn).toHaveBeenCalled(); + }); + + it('errors and cleans up when spawn fails synchronously', () => { + mockSpawn.mockImplementation(() => { + throw new Error('spawn error'); + }); + + startNode({ network: Network.devnet, daemon: true }); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to spawn daemon process'), + expect.any(Error), + ); + expect(mockCloseSync).toHaveBeenCalledWith(3); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('errors when the spawned child has no PID', () => { + mockSpawn.mockReturnValue({ + pid: undefined, + unref: jest.fn(), + on: jest.fn(), + }); + + startNode({ network: Network.devnet, daemon: true }); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('no PID returned')); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('handles backward-compatible plain PID files', () => { + mockReadFileSync.mockReturnValue('9999'); + killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + const err = new Error('ESRCH') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + } + return true; + }); + + startNode({ network: Network.devnet, daemon: true }); + + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(mockSpawn).toHaveBeenCalled(); + }); +}); + +describe('node command stop', () => { + let killSpy: jest.SpyInstance; + let processAlive = true; + const scriptPath = '/path/to/offckb'; + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, 'platform', { value }); + } + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + processAlive = true; + mockExec.mockReset(); + mockStatSync.mockReturnValue({ isFile: () => true }); + mockReadFileSync.mockReturnValue(JSON.stringify({ pid: 12345, scriptPath, startedAt: new Date().toISOString() })); + mockDaemonCommandLine(scriptPath); + + // Normalize to POSIX for deterministic signal-based assertions. The + // implementation has a separate Windows path (taskkill) that is exercised + // by the daemon-mode spawn tests above and by integration tests. + setPlatform('linux'); + + killSpy = jest.spyOn(process, 'kill').mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + if (!processAlive) { + const err = new Error('ESRCH') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + } + return true; + } + if (signal === 'SIGTERM' || signal === 'SIGKILL') { + processAlive = false; + return true; + } + return true; + }); + }); + + afterEach(() => { + killSpy.mockRestore(); + setPlatform(originalPlatform); + jest.useRealTimers(); + }); + + it('warns when no PID file exists', async () => { + mockReadFileSync.mockImplementation(() => { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + throw err; + }); + + await stopNode(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon PID file found')); + expect(killSpy).not.toHaveBeenCalled(); + }); + + it('errors when the PID file contains an invalid PID', async () => { + mockReadFileSync.mockReturnValue('not-a-number'); + await stopNode(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid PID')); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); + + it('removes the PID file when the daemon is not running', async () => { + processAlive = false; + await stopNode(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('is not running')); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); + + it('stops the daemon gracefully with SIGTERM', async () => { + await stopNode(); + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGTERM'); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + expect(logger.success).toHaveBeenCalledWith('CKB devnet daemon stopped.'); + }); + + it('falls back to SIGKILL when the daemon does not exit gracefully', async () => { + // Simulate a process that ignores SIGTERM but dies on SIGKILL. + killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + return true; + } + if (signal === 'SIGKILL') { + processAlive = false; + } + return true; + }); + + const stopPromise = stopNode(); + await jest.advanceTimersByTimeAsync(5000); + await stopPromise; + + expect(killSpy).toHaveBeenCalledWith(-12345, 'SIGKILL'); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); + + it('refuses to kill a process that does not look like the daemon', async () => { + mockExec.mockImplementation((cmd: string, callback: (err: Error | null, stdout?: string) => void) => { + callback(null, '/usr/bin/some-other-process'); + return undefined as unknown as ReturnType; + }); + + await stopNode(); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('does not appear to be the offckb daemon')); + expect(killSpy).not.toHaveBeenCalledWith(expect.any(Number), 'SIGTERM'); + expect(mockUnlinkSync).not.toHaveBeenCalled(); + }); + + it('cleans up the PID file when SIGTERM fails with an unknown error', async () => { + killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + return true; + } + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + }); + + await stopNode(); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Permission denied')); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); + + it('cleans up the PID file when the process disappears between alive-check and SIGTERM', async () => { + killSpy.mockImplementation((pid: number, signal?: NodeJS.Signals | number) => { + if (signal === 0) { + return true; + } + const err = new Error('ESRCH') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }); + + await stopNode(); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('is not running')); + expect(mockUnlinkSync).toHaveBeenCalledWith(pidFile); + }); +});