diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7da370ce3fe..bbc8c2bedbe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -118,6 +118,8 @@ /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/wallet @MetaMask/core-platform +/packages/wallet-cli @MetaMask/core-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/README.md b/README.md index 13dae34ef03..dd9845358a0 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/transaction-pay-controller`](packages/transaction-pay-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) - [`@metamask/wallet`](packages/wallet) +- [`@metamask/wallet-cli`](packages/wallet-cli) @@ -185,6 +186,7 @@ linkStyle default opacity:0.5 transaction_pay_controller(["@metamask/transaction-pay-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); wallet(["@metamask/wallet"]); + wallet_cli(["@metamask/wallet-cli"]); account_tree_controller --> accounts_controller; account_tree_controller --> base_controller; account_tree_controller --> keyring_controller; @@ -522,6 +524,17 @@ linkStyle default opacity:0.5 user_operation_controller --> polling_controller; user_operation_controller --> transaction_controller; user_operation_controller --> eth_block_tracker; + wallet --> accounts_controller; + wallet --> approval_controller; + wallet --> connectivity_controller; + wallet --> controller_utils; + wallet --> keyring_controller; + wallet --> messenger; + wallet --> network_controller; + wallet --> remote_feature_flag_controller; + wallet --> transaction_controller; + wallet_cli --> remote_feature_flag_controller; + wallet_cli --> wallet; ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 492f1c99c3c..4ebdcdd548f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -287,6 +287,27 @@ const config = createConfig([ 'n/no-deprecated-api': 'off', }, }, + { + files: ['packages/wallet-cli/src/**/*.{js,ts}'], + rules: { + 'import-x/no-nodejs-modules': 'off', + 'no-restricted-globals': 'off', + }, + }, + { + files: ['packages/wallet-cli/src/**/*.test.{js,ts}'], + rules: { + 'jest/unbound-method': 'off', + 'n/no-process-env': 'off', + 'n/no-sync': 'off', + }, + }, + { + files: ['packages/wallet-cli/bin/**/*.mjs'], + rules: { + 'import-x/no-unresolved': 'off', + }, + }, { files: ['packages/messenger/src/generate-action-types/**/*.{js,ts}'], rules: { diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/wallet-cli/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/LICENSE b/packages/wallet-cli/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/wallet-cli/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md new file mode 100644 index 00000000000..1de99a32fa7 --- /dev/null +++ b/packages/wallet-cli/README.md @@ -0,0 +1,15 @@ +# `@metamask/wallet-cli` + +The CLI of @metamask/wallet + +## Installation + +`yarn add @metamask/wallet-cli` + +or + +`npm install @metamask/wallet-cli` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/wallet-cli/bin/dev.cmd b/packages/wallet-cli/bin/dev.cmd new file mode 100644 index 00000000000..ee0f58bfe9b --- /dev/null +++ b/packages/wallet-cli/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node --loader tsx --no-warnings=ExperimentalWarning "%~dp0\dev" %* diff --git a/packages/wallet-cli/bin/dev.mjs b/packages/wallet-cli/bin/dev.mjs new file mode 100755 index 00000000000..857ef9d96b8 --- /dev/null +++ b/packages/wallet-cli/bin/dev.mjs @@ -0,0 +1,3 @@ +import { execute } from '@oclif/core'; + +await execute({ development: true, dir: import.meta.url }); diff --git a/packages/wallet-cli/bin/run.cmd b/packages/wallet-cli/bin/run.cmd new file mode 100644 index 00000000000..968fc30758e --- /dev/null +++ b/packages/wallet-cli/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/packages/wallet-cli/bin/run.mjs b/packages/wallet-cli/bin/run.mjs new file mode 100755 index 00000000000..176d2af58c5 --- /dev/null +++ b/packages/wallet-cli/bin/run.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { execute } from '@oclif/core'; + +await execute({ dir: import.meta.url }); diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js new file mode 100644 index 00000000000..e863064fbc6 --- /dev/null +++ b/packages/wallet-cli/jest.config.js @@ -0,0 +1,29 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // TODO: Add tests for commands + coveragePathIgnorePatterns: ['.*/commands/.*'], + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json new file mode 100644 index 00000000000..ed62fb7439f --- /dev/null +++ b/packages/wallet-cli/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/wallet-cli", + "version": "0.0.0", + "description": "The CLI of @metamask/wallet", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/wallet-cli#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + "./package.json": "./package.json" + }, + "bin": { + "mm": "./bin/run.mjs" + }, + "files": [ + "bin/", + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/wallet-cli", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet-cli", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "oclif": { + "bin": "mm", + "commands": "./dist/commands", + "dirname": "mm", + "topicSeparator": " " + }, + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@metamask/remote-feature-flag-controller": "^4.2.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/utils": "^11.9.0", + "@metamask/wallet": "^0.0.0", + "@oclif/core": "^4.10.5" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/wallet-cli/src/commands/daemon/call.ts b/packages/wallet-cli/src/commands/daemon/call.ts new file mode 100644 index 00000000000..8feac20ff7a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -0,0 +1,80 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Args, Command, Flags } from '@oclif/core'; + +import { sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; + +export default class DaemonCall extends Command { + static override description = 'Call a messenger action on the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon call AccountsController:listAccounts', + '<%= config.bin %> daemon call NetworkController:getState', + '<%= config.bin %> daemon call KeyringController:getState --timeout 10000', + ]; + + static override args = { + action: Args.string({ + description: + 'The messenger action name (e.g. AccountsController:listAccounts)', + required: true, + }), + params: Args.string({ + description: 'JSON-encoded arguments array (e.g. \'["arg1", "arg2"]\')', + required: false, + }), + }; + + static override flags = { + timeout: Flags.integer({ + char: 't', + description: 'Response timeout in milliseconds', + required: false, + }), + }; + + public async run(): Promise { + const { args, flags } = await this.parse(DaemonCall); + const { action } = args; + const timeoutMs = flags.timeout; + + // Build the params array for the `call` RPC method: [action, ...args] + let rpcParams: unknown[] = [action]; + if (args.params !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(args.params); + } catch { + this.error('params must be valid JSON'); + } + + if (!Array.isArray(parsed)) { + this.error('params must be a JSON array'); + } + + rpcParams = [action, ...parsed]; + } + + const { socketPath } = getDaemonPaths(this.config.dataDir); + + const response = await sendCommand({ + socketPath, + method: 'call', + params: rpcParams, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + + if (isJsonRpcFailure(response)) { + this.error( + `${response.error.message} (code ${String(response.error.code)})`, + ); + } + + const isTTY = process.stdout.isTTY ?? false; + if (isTTY) { + this.log(JSON.stringify(response.result, null, 2)); + } else { + process.stdout.write(`${JSON.stringify(response.result)}\n`); + } + } +} diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts new file mode 100644 index 00000000000..2a09db3ee9c --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -0,0 +1,52 @@ +import { Command, Flags } from '@oclif/core'; +import { rm } from 'node:fs/promises'; + +import { getDaemonPaths } from '../../daemon/paths'; +import { stopDaemon } from '../../daemon/stop-daemon'; + +export default class DaemonPurge extends Command { + static override description = + 'Stop the daemon and delete all daemon state files'; + + static override examples = [ + '<%= config.bin %> daemon purge', + '<%= config.bin %> daemon purge --force', + ]; + + static override flags = { + force: Flags.boolean({ + char: 'f', + description: 'Skip confirmation prompt', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DaemonPurge); + + if (!flags.force) { + const { default: confirm } = await import('@inquirer/confirm'); + const confirmed = await confirm({ + message: 'This will stop the daemon and delete all state. Continue?', + default: false, + }); + if (!confirmed) { + this.log('Aborted.'); + return; + } + } + + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + const stopped = await stopDaemon(socketPath, pidPath, (message) => + this.log(message), + ); + + if (!stopped) { + this.error('Refusing to delete state while the daemon is still running.'); + } + + await rm(this.config.dataDir, { recursive: true, force: true }); + + this.log('All daemon state deleted.'); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts new file mode 100644 index 00000000000..e0223a99ce9 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -0,0 +1,51 @@ +import { Command, Flags } from '@oclif/core'; + +import { ensureDaemon } from '../../daemon/daemon-spawn'; +import { getDaemonPaths } from '../../daemon/paths'; + +export default class DaemonStart extends Command { + static override description = 'Start the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon start --infura-project-id --password --srp ', + 'INFURA_PROJECT_ID= MM_WALLET_PASSWORD= MM_WALLET_SRP= <%= config.bin %> daemon start', + ]; + + // TODO: Delete unsafe flags + static override flags = { + 'infura-project-id': Flags.string({ + description: 'Infura project ID for network access', + env: 'INFURA_PROJECT_ID', + required: true, + }), + password: Flags.string({ + description: + 'Wallet password (testing only — use MM_WALLET_PASSWORD env var in production)', + env: 'MM_WALLET_PASSWORD', + required: true, + }), + srp: Flags.string({ + description: + 'Secret recovery phrase (testing only — use MM_WALLET_SRP env var in production)', + env: 'MM_WALLET_SRP', + required: true, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DaemonStart); + const infuraProjectId = flags['infura-project-id']; + const { password, srp } = flags; + + await ensureDaemon({ + dataDir: this.config.dataDir, + infuraProjectId, + password, + srp, + packageRoot: this.config.root, + }); + + const { socketPath } = getDaemonPaths(this.config.dataDir); + this.log(`Daemon running. Socket: ${socketPath}`); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts new file mode 100644 index 00000000000..f8a2b618209 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -0,0 +1,59 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Command } from '@oclif/core'; + +import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import type { DaemonStatusInfo } from '../../daemon/types'; +import { isProcessAlive, readPidFile } from '../../daemon/utils'; + +export default class DaemonStatus extends Command { + static override description = 'Check the status of the wallet daemon'; + + static override examples = ['<%= config.bin %> daemon status']; + + public async run(): Promise { + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + const pid = await readPidFile(pidPath); + const processAlive = pid !== undefined && isProcessAlive(pid); + const socketResponsive = await pingDaemon(socketPath); + + if (!processAlive && !socketResponsive) { + this.log('Daemon is not running.'); + return; + } + + if (processAlive && !socketResponsive) { + this.log( + `Daemon process exists (PID: ${pid}) but socket is not responding.`, + ); + return; + } + + let response; + try { + response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 5_000, + }); + } catch (error) { + this.log( + `Daemon socket is responsive but status request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + + if (isJsonRpcFailure(response)) { + this.log( + `Daemon is running but returned an error: ${response.error.message}`, + ); + return; + } + + const status = response.result as DaemonStatusInfo; + this.log( + `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, + ); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/stop.ts b/packages/wallet-cli/src/commands/daemon/stop.ts new file mode 100644 index 00000000000..5df3eb64b1a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -0,0 +1,22 @@ +import { Command } from '@oclif/core'; + +import { getDaemonPaths } from '../../daemon/paths'; +import { stopDaemon } from '../../daemon/stop-daemon'; + +export default class DaemonStop extends Command { + static override description = 'Stop the wallet daemon'; + + static override examples = ['<%= config.bin %> daemon stop']; + + public async run(): Promise { + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + const stopped = await stopDaemon(socketPath, pidPath, (message) => + this.log(message), + ); + + if (!stopped) { + this.error('Daemon did not stop within timeout.'); + } + } +} diff --git a/packages/wallet-cli/src/daemon/daemon-client.test.ts b/packages/wallet-cli/src/daemon/daemon-client.test.ts new file mode 100644 index 00000000000..a3cf69f717b --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-client.test.ts @@ -0,0 +1,201 @@ +import type { JsonRpcResponse } from '@metamask/utils'; +import { EventEmitter } from 'node:events'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; + +import { sendCommand, pingDaemon } from './daemon-client'; +import { readLine, writeLine } from './socket-line'; + +jest.mock('node:net'); +jest.mock('./socket-line'); + +const mockCreateConnection = jest.mocked(createConnection); +const mockReadLine = jest.mocked(readLine); +const mockWriteLine = jest.mocked(writeLine); + +/** + * Create a mock Socket and wire up createConnection to return it. + * The connection callback is deferred via process.nextTick to match + * real behavior (the `socket` const must be assigned before the callback + * references it). + * + * @returns The mock socket. + */ +function setupMockSocket(): Socket { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; + + mockCreateConnection.mockImplementation( + (_path: unknown, callback: unknown) => { + process.nextTick(() => (callback as () => void)()); + return socket; + }, + ); + + return socket; +} + +const VALID_RESPONSE: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'test-id', + result: { status: 'ok' }, +}; + +describe('sendCommand', () => { + it('sends a JSON-RPC request and returns the response', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'getStatus', + }); + + expect(mockCreateConnection).toHaveBeenCalledWith( + '/tmp/test.sock', + expect.any(Function), + ); + expect(mockWriteLine).toHaveBeenCalledWith( + socket, + expect.stringContaining('"method":"getStatus"'), + ); + expect(response.result).toStrictEqual({ status: 'ok' }); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('includes params when provided', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + params: { key: 'value' }, + }); + + const written = mockWriteLine.mock.calls[0][1]; + expect(JSON.parse(written)).toHaveProperty('params', { key: 'value' }); + }); + + it('omits params when undefined', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + const written = mockWriteLine.mock.calls[0][1]; + expect(JSON.parse(written)).not.toHaveProperty('params'); + }); + + it('passes timeoutMs to readLine', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + timeoutMs: 5000, + }); + + expect(mockReadLine).toHaveBeenCalledWith(expect.anything(), 5000); + }); + + it('retries once on ECONNREFUSED', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine + .mockRejectedValueOnce( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ) + .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + expect(response.result).toStrictEqual({ status: 'ok' }); + expect(socket.destroy).toHaveBeenCalledTimes(2); + }); + + it('retries once on ECONNRESET', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine + .mockRejectedValueOnce( + Object.assign(new Error('reset'), { code: 'ECONNRESET' }), + ) + .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + expect(response).toHaveProperty('result'); + }); + + it('does not retry on other errors', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockRejectedValue(new Error('parse error')); + + await expect( + sendCommand({ socketPath: '/tmp/test.sock', method: 'test' }), + ).rejects.toThrow('parse error'); + + expect(mockReadLine).toHaveBeenCalledTimes(1); + }); + + it('destroys socket even when attempt throws', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockRejectedValue(new Error('write error')); + + await expect( + sendCommand({ socketPath: '/tmp/test.sock', method: 'test' }), + ).rejects.toThrow('write error'); + + expect(socket.destroy).toHaveBeenCalled(); + }); +}); + +describe('pingDaemon', () => { + it('returns true when daemon responds', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + expect(await pingDaemon('/tmp/test.sock')).toBe(true); + }); + + it('returns false when daemon is unresponsive', async () => { + mockCreateConnection.mockImplementation((_path: unknown) => { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; + process.nextTick(() => + socket.emit( + 'error', + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ), + ); + return socket; + }); + + expect(await pingDaemon('/tmp/test.sock')).toBe(false); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts new file mode 100644 index 00000000000..dd80f06753e --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -0,0 +1,111 @@ +import type { JsonRpcResponse } from '@metamask/utils'; +import { assertIsJsonRpcResponse } from '@metamask/utils'; +import { randomUUID } from 'node:crypto'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; + +import { readLine, writeLine } from './socket-line'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Options for {@link sendCommand}. + */ +type SendCommandOptions = { + /** The Unix socket path. */ + socketPath: string; + /** The RPC method name. */ + method: string; + /** Optional method parameters (object or positional array). */ + params?: Record | unknown[] | undefined; + /** Response read timeout in milliseconds (default: 30 000). */ + timeoutMs?: number | undefined; +}; + +/** + * Connect to a Unix domain socket. + * + * @param socketPath - The socket path to connect to. + * @returns A connected socket. + */ +async function connectSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath, () => { + socket.removeListener('error', reject); + resolve(socket); + }); + socket.on('error', reject); + }); +} + +/** + * Send a JSON-RPC request to the daemon over a Unix socket and return the + * response. + * + * Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC + * response line, then closes the connection. Retries once after a short delay + * on transient connection errors (ECONNREFUSED, ECONNRESET). + * + * @param options - Command options. + * @param options.socketPath - The Unix socket path. + * @param options.method - The RPC method name. + * @param options.params - Optional method parameters. + * @param options.timeoutMs - Read timeout in milliseconds. + * @returns The parsed JSON-RPC response. + */ +export async function sendCommand({ + socketPath, + method, + params, + timeoutMs, +}: SendCommandOptions): Promise { + const id = randomUUID(); + const request = { + jsonrpc: '2.0', + id, + method, + ...(params === undefined ? {} : { params }), + }; + + const effectiveTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; + + const attempt = async (): Promise => { + const socket = await connectSocket(socketPath); + try { + await writeLine(socket, JSON.stringify(request)); + const responseLine = await readLine(socket, effectiveTimeout); + const parsed: unknown = JSON.parse(responseLine); + assertIsJsonRpcResponse(parsed); + return parsed; + } finally { + socket.destroy(); + } + }; + + try { + return await attempt(); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + return attempt(); + } +} + +/** + * Check whether the daemon is running by sending a lightweight `getStatus` + * RPC call. + * + * @param socketPath - The Unix socket path. + * @returns True if the daemon responds to the RPC call. + */ +export async function pingDaemon(socketPath: string): Promise { + try { + await sendCommand({ socketPath, method: 'getStatus', timeoutMs: 3_000 }); + return true; + } catch { + return false; + } +} diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts new file mode 100644 index 00000000000..2e0e57193a4 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -0,0 +1,508 @@ +import { mkdirSync } from 'node:fs'; +import { appendFile, rm, writeFile } from 'node:fs/promises'; + +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import { createWallet } from './wallet-factory'; + +jest.mock('node:fs'); +jest.mock('node:fs/promises'); +jest.mock('./paths'); +jest.mock('./rpc-socket-server'); +jest.mock('./wallet-factory'); + +const mockMkdirSync = jest.mocked(mkdirSync); +const mockAppendFile = jest.mocked(appendFile); +const mockWriteFile = jest.mocked(writeFile); +const mockRm = jest.mocked(rm); +const mockGetDaemonPaths = jest.mocked(getDaemonPaths); +const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); +const mockCreateWallet = jest.mocked(createWallet); + +const ORIGINAL_ENV = process.env; + +/** + * Create a mock wallet. + * + * @returns A mock wallet object. + */ +function createMockWallet(): Awaited> { + return { + messenger: { call: jest.fn() } as never, + state: {} as never, + destroy: jest.fn().mockResolvedValue(undefined), + } as unknown as Awaited>; +} + +/** + * Create a mock server handle. + * + * @returns A mock server handle. + */ +function createMockHandle(): RpcSocketServerHandle { + return { close: jest.fn().mockResolvedValue(undefined) }; +} + +describe('daemon-entry', () => { + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + process.env.MM_WALLET_PASSWORD = 'pass'; + process.env.MM_WALLET_SRP = + 'test test test test test test test test test test test ball'; + process.exitCode = undefined; + stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + mockGetDaemonPaths.mockReturnValue({ + socketPath: '/tmp/daemon.sock', + pidPath: '/tmp/daemon.pid', + logPath: '/tmp/daemon.log', + }); + mockWriteFile.mockResolvedValue(undefined); + mockRm.mockResolvedValue(undefined); + mockAppendFile.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + process.exitCode = undefined; + }); + + /** + * Import daemon-entry in an isolated module scope so its top-level + * main() runs with the current mocks and env vars. + * Returns after main() settles. + */ + async function importDaemonEntry(): Promise { + // The module under test calls main() at top level on import. + // We use jest.isolateModules to re-import it fresh in each test + // after setting up mocks and env vars. + await jest.isolateModulesAsync(async () => { + await import('./daemon-entry'); + // Flush microtasks so main()'s .catch() handler settles + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + }); + } + + it('writes to stderr and sets exitCode when MM_DAEMON_DATA_DIR is missing', async () => { + delete process.env.MM_DAEMON_DATA_DIR; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_DAEMON_DATA_DIR'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when INFURA_PROJECT_ID is missing', async () => { + delete process.env.INFURA_PROJECT_ID; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('INFURA_PROJECT_ID'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when MM_WALLET_PASSWORD is missing', async () => { + delete process.env.MM_WALLET_PASSWORD; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_PASSWORD'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when MM_WALLET_SRP is missing', async () => { + delete process.env.MM_WALLET_SRP; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_SRP'), + ); + expect(process.exitCode).toBe(1); + }); + + it('creates data dir, wallet, server, and writes PID on successful startup', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { + recursive: true, + }); + expect(mockCreateWallet).toHaveBeenCalledWith({ + infuraProjectId: 'key', + password: 'pass', + srp: 'test test test test test test test test test test test ball', + }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + String(process.pid), + ); + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/tmp/daemon.sock', + }), + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('uses MM_DAEMON_SOCKET_PATH override when set', async () => { + process.env.MM_DAEMON_SOCKET_PATH = '/custom/sock'; + + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/custom/sock', + }), + ); + }); + + it('cleans up wallet and PID file when server fails to start', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + + await importDaemonEntry(); + + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('still cleans up PID when wallet.destroy fails during error cleanup', async () => { + const wallet = createMockWallet(); + (wallet.destroy as jest.Mock).mockRejectedValue( + new Error('destroy failed'), + ); + mockCreateWallet.mockResolvedValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('exposes getStatus handler that returns pid and uptime', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Extract the handlers passed to startRpcSocketServer + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const { handlers } = callArgs; + const status = (await handlers.getStatus(null)) as { + pid: number; + uptime: number; + }; + + expect(status.pid).toBe(process.pid); + expect(typeof status.uptime).toBe('number'); + }); + + it('logs to file via makeLogger', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // makeLogger writes via appendFile to the log path + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Starting daemon...'), + ); + }); + + it('writes to stderr when appendFile fails in makeLogger', async () => { + mockAppendFile.mockRejectedValue(new Error('disk full')); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Flush the appendFile rejection handler + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('log write failed'), + ); + }); + + it('registers SIGTERM and SIGINT handlers', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const registeredEvents = onSpy.mock.calls.map(([event]) => event); + expect(registeredEvents).toContain('SIGTERM'); + expect(registeredEvents).toContain('SIGINT'); + }); + + it('triggers shutdown when SIGTERM handler is called', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const sigTermCall = onSpy.mock.calls.find(([event]) => event === 'SIGTERM'); + const sigTermHandler = sigTermCall?.[1] as () => void; + sigTermHandler(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + }); + + it('triggers shutdown when SIGINT handler is called', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const sigIntCall = onSpy.mock.calls.find(([event]) => event === 'SIGINT'); + const sigIntHandler = sigIntCall?.[1] as () => void; + sigIntHandler(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + }); + + it('shutdown still calls wallet.destroy when handle.close fails', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('handle.close() failed'), + ); + }); + + it('shutdown logs wallet.destroy failure', async () => { + const wallet = createMockWallet(); + (wallet.destroy as jest.Mock).mockRejectedValue( + new Error('destroy failed'), + ); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('wallet.destroy() failed'), + ); + }); + + it('handles rm rejection during shutdown cleanup gracefully', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + // rm rejects but cleanup should not fail + mockRm.mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + }); + + it('handles rm rejection in error cleanup path gracefully', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + mockRm.mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + expect(process.exitCode).toBe(1); + }); + + it('onShutdown closes server and destroys wallet', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + // Extract the onShutdown callback + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + }); + + describe('call handler', () => { + /** + * Import the daemon entry and extract the `call` handler from the + * handlers map, along with the mock wallet for assertions. + * + * @returns The call handler function and mock wallet. + */ + async function setupCallHandler(): Promise<{ + callHandler: (params: unknown) => Promise; + wallet: Awaited>; + }> { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const callHandler = callArgs.handlers.call as ( + params: unknown, + ) => Promise; + return { callHandler, wallet }; + } + + it('registers a call handler', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + expect(typeof callArgs.handlers.call).toBe('function'); + }); + + it('forwards action and args to messenger.call', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue({ accounts: [] }); + + const result = await callHandler(['Controller:action', 'arg1', 'arg2']); + + expect(mockCall).toHaveBeenCalledWith( + 'Controller:action', + 'arg1', + 'arg2', + ); + expect(result).toStrictEqual({ accounts: [] }); + }); + + it('calls messenger.call with no extra args when only action is provided', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue('ok'); + + await callHandler(['Controller:action']); + + expect(mockCall).toHaveBeenCalledWith('Controller:action'); + }); + + it('awaits async messenger.call results', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockResolvedValue({ async: true }); + + const result = await callHandler(['Controller:asyncAction']); + + expect(result).toStrictEqual({ async: true }); + }); + + it('propagates errors thrown by messenger.call', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockImplementation(() => { + throw new Error('A handler for Unknown:action has not been registered'); + }); + + await expect(callHandler(['Unknown:action'])).rejects.toThrow( + 'A handler for Unknown:action has not been registered', + ); + }); + + it('throws when params is null', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler(null)).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + + it('throws when params is an empty array', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + + it('throws when action name is not a string', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([42])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts new file mode 100644 index 00000000000..2ae410462ac --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -0,0 +1,156 @@ +import type { Json } from '@metamask/utils'; +import { mkdirSync } from 'node:fs'; +import { appendFile, rm, writeFile } from 'node:fs/promises'; + +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import type { DaemonStatusInfo, RpcHandlerMap } from './types'; +import { createWallet } from './wallet-factory'; + +const startTime = Date.now(); + +main().catch((error: unknown) => { + process.stderr.write(`Daemon fatal: ${String(error)}\n`); + process.exitCode = 1; +}); + +/** + * Main daemon entry point. Starts the daemon process and keeps it running. + */ +async function main(): Promise { + const dataDir = process.env.MM_DAEMON_DATA_DIR; + if (!dataDir) { + throw new Error('MM_DAEMON_DATA_DIR environment variable is required'); + } + + const infuraProjectId = process.env.INFURA_PROJECT_ID; + if (!infuraProjectId) { + throw new Error('INFURA_PROJECT_ID environment variable is required'); + } + + const password = process.env.MM_WALLET_PASSWORD; + if (!password) { + throw new Error('MM_WALLET_PASSWORD environment variable is required'); + } + + const srp = process.env.MM_WALLET_SRP; + if (!srp) { + throw new Error('MM_WALLET_SRP environment variable is required'); + } + + mkdirSync(dataDir, { recursive: true }); + + const { + socketPath: defaultSocketPath, + pidPath, + logPath, + } = getDaemonPaths(dataDir); + const socketPath = process.env.MM_DAEMON_SOCKET_PATH ?? defaultSocketPath; + + const log = makeLogger(logPath); + log('Starting daemon...'); + + const wallet = await createWallet({ infuraProjectId, password, srp }); + + const handlers: RpcHandlerMap = { + getStatus: async (): Promise => ({ + pid: process.pid, + uptime: Math.floor((Date.now() - startTime) / 1000), + }), + // Arbitrary messenger dispatch is intentional: the CLI exposes the full + // messenger surface over a local Unix socket. Access control is enforced + // at the socket level (only local users can connect). + call: async (params) => { + if (!Array.isArray(params) || typeof params[0] !== 'string') { + throw new Error('Expected params to be an array with an action name'); + } + const [action, ...args] = params as [string, ...Json[]]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. + const result = (wallet.messenger as any).call(action, ...args); + return (result instanceof Promise ? await result : result) as Json; + }, + }; + + let handle: RpcSocketServerHandle; + try { + await writeFile(pidPath, String(process.pid)); + + handle = await startRpcSocketServer({ + socketPath, + handlers, + onShutdown: async () => shutdown('RPC shutdown'), + }); + } catch (error) { + try { + await wallet.destroy(); + } catch (destroyError) { + log(`wallet.destroy() failed during cleanup: ${String(destroyError)}`); + } + await rm(pidPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove PID file during cleanup: ${String(rmError)}`); + }); + throw error; + } + + log(`Daemon started. Socket: ${socketPath}`); + + let shutdownPromise: Promise | undefined; + + /** + * Shut down the daemon idempotently. Concurrent calls coalesce. + * + * @param reason - A label describing why shutdown was triggered. + * @returns A promise that resolves when shutdown completes. + */ + async function shutdown(reason: string): Promise { + if (shutdownPromise === undefined) { + log(`Shutting down (${reason})...`); + shutdownPromise = (async (): Promise => { + try { + await handle.close(); + } catch (closeError) { + log(`handle.close() failed: ${String(closeError)}`); + } + try { + await wallet.destroy(); + } catch (destroyError) { + log(`wallet.destroy() failed: ${String(destroyError)}`); + } + await Promise.all([ + rm(pidPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove PID file: ${String(rmError)}`); + }), + rm(socketPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove socket file: ${String(rmError)}`); + }), + ]); + })(); + } + return shutdownPromise; + } + + process.on('SIGTERM', () => { + /* istanbul ignore next */ + shutdown('SIGTERM').catch(() => undefined); + }); + process.on('SIGINT', () => { + /* istanbul ignore next */ + shutdown('SIGINT').catch(() => undefined); + }); +} + +/** + * Create a simple file logger. + * + * @param logPath - The log file path. + * @returns A logging function. + */ +function makeLogger(logPath: string): (message: string) => void { + return (message: string): void => { + const line = `[${new Date().toISOString()}] ${message}\n`; + appendFile(logPath, line).catch((error: unknown) => { + process.stderr.write(`[log write failed: ${String(error)}] ${message}\n`); + }); + }; +} diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts new file mode 100644 index 00000000000..2e42e7ae065 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -0,0 +1,162 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +import { pingDaemon } from './daemon-client'; +import { ensureDaemon } from './daemon-spawn'; +import { getDaemonPaths } from './paths'; +import type { DaemonSpawnConfig } from './types'; + +jest.mock('node:child_process'); +jest.mock('node:fs'); +jest.mock('./daemon-client'); +jest.mock('./paths'); + +const mockSpawn = jest.mocked(spawn); +const mockExistsSync = jest.mocked(existsSync); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockGetDaemonPaths = jest.mocked(getDaemonPaths); + +const CONFIG: DaemonSpawnConfig = { + dataDir: '/tmp/data', + infuraProjectId: 'test-key', + password: 'test-pass', + srp: 'test test test test test test test test test test test ball', + packageRoot: '/pkg', +}; + +describe('ensureDaemon', () => { + beforeEach(() => { + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockGetDaemonPaths.mockReturnValue({ + socketPath: '/tmp/test.sock', + pidPath: '/tmp/test.pid', + logPath: '/tmp/test.log', + }); + mockSpawn.mockReturnValue({ + unref: jest.fn(), + on: jest.fn(), + } as never); + }); + + it('returns immediately if daemon is already running', async () => { + mockPingDaemon.mockResolvedValue(true); + + await ensureDaemon(CONFIG); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('spawns daemon as detached child with correct env vars', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + + await ensureDaemon(CONFIG); + + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + ['/pkg/dist/daemon/daemon-entry.mjs'], + expect.objectContaining({ + detached: true, + stdio: 'ignore', + env: expect.objectContaining({ + MM_DAEMON_DATA_DIR: '/tmp/data', + INFURA_PROJECT_ID: 'test-key', + MM_WALLET_PASSWORD: 'test-pass', + MM_WALLET_SRP: + 'test test test test test test test test test test test ball', + }), + }), + ); + }); + + it('uses dist entry when it exists', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + + await ensureDaemon(CONFIG); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toStrictEqual(['/pkg/dist/daemon/daemon-entry.mjs']); + }); + + it('falls back to src entry with tsx when dist missing', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(false); + + await ensureDaemon(CONFIG); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toStrictEqual([ + '--import', + 'tsx', + '/pkg/src/daemon/daemon-entry.ts', + ]); + }); + + it('polls until daemon is ready', async () => { + mockPingDaemon + .mockResolvedValueOnce(false) // initial check + .mockResolvedValueOnce(false) // poll 1 + .mockResolvedValueOnce(false) // poll 2 + .mockResolvedValueOnce(true); // poll 3 + mockExistsSync.mockReturnValue(true); + + await ensureDaemon(CONFIG); + + expect(mockPingDaemon).toHaveBeenCalledTimes(4); + expect(process.stderr.write).toHaveBeenCalledWith('Daemon ready.\n'); + }); + + it('throws after timeout when daemon never responds', async () => { + jest.useFakeTimers(); + mockPingDaemon.mockResolvedValue(false); + mockExistsSync.mockReturnValue(true); + + const promise = ensureDaemon(CONFIG); + // Attach rejection handler before advancing timers to avoid unhandled rejection + const rejection = promise.catch((thrown: unknown) => thrown); + + // Advance past all 300 polls (100ms each = 30s) + await jest.advanceTimersByTimeAsync(30_100); + const thrownError = await rejection; + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe( + 'Daemon did not start within 30s', + ); + jest.useRealTimers(); + }); + + it('calls unref on spawned child and registers error handler', async () => { + const unref = jest.fn(); + const on = jest.fn(); + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + mockSpawn.mockReturnValue({ unref, on } as never); + + await ensureDaemon(CONFIG); + + expect(unref).toHaveBeenCalled(); + expect(on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('writes spawn errors to stderr', async () => { + const unref = jest.fn(); + let errorHandler: ((error: Error) => void) | undefined; + const on = jest.fn( + (event: string, handler: (error: Error) => void): void => { + if (event === 'error') { + errorHandler = handler; + } + }, + ); + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + mockSpawn.mockReturnValue({ unref, on } as never); + + await ensureDaemon(CONFIG); + errorHandler?.(new Error('spawn ENOENT')); + + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Failed to spawn daemon process'), + ); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts new file mode 100644 index 00000000000..5a63815d5d7 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -0,0 +1,80 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { pingDaemon } from './daemon-client'; +import { getDaemonPaths } from './paths'; +import type { DaemonSpawnConfig } from './types'; + +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 config - Spawn configuration. + */ +export async function ensureDaemon(config: DaemonSpawnConfig): Promise { + const { socketPath } = getDaemonPaths(config.dataDir); + if (await pingDaemon(socketPath)) { + return; + } + + process.stderr.write('Starting daemon...\n'); + + const { entryPath, args } = resolveEntryPoint(config.packageRoot); + + const child = spawn(process.execPath, [...args, entryPath], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + MM_DAEMON_DATA_DIR: config.dataDir, + INFURA_PROJECT_ID: config.infuraProjectId, + MM_WALLET_PASSWORD: config.password, + MM_WALLET_SRP: config.srp, + }, + }); + child.on('error', (error) => { + process.stderr.write(`Failed to spawn daemon process: ${String(error)}\n`); + }); + child.unref(); + + for (let i = 0; i < MAX_POLLS; i++) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (await pingDaemon(socketPath)) { + process.stderr.write('Daemon ready.\n'); + return; + } + } + + throw new Error( + `Daemon did not start within ${(MAX_POLLS * POLL_INTERVAL_MS) / 1000}s`, + ); +} + +/** + * Resolve the daemon entry point path and any extra Node.js args needed. + * + * In production, uses the compiled dist output. In development, uses tsx + * to run TypeScript source directly. + * + * @param packageRoot - The root directory of the wallet-cli package. + * @returns The entry path and any extra node args. + */ +function resolveEntryPoint(packageRoot: string): { + entryPath: string; + args: string[]; +} { + const distEntry = join(packageRoot, 'dist', 'daemon', 'daemon-entry.mjs'); + if (existsSync(distEntry)) { + return { entryPath: distEntry, args: [] }; + } + + const srcEntry = join(packageRoot, 'src', 'daemon', 'daemon-entry.ts'); + return { + entryPath: srcEntry, + args: ['--import', 'tsx'], + }; +} diff --git a/packages/wallet-cli/src/daemon/paths.test.ts b/packages/wallet-cli/src/daemon/paths.test.ts new file mode 100644 index 00000000000..5ea1f81f092 --- /dev/null +++ b/packages/wallet-cli/src/daemon/paths.test.ts @@ -0,0 +1,16 @@ +import { join } from 'node:path'; + +import { getDaemonPaths } from './paths'; + +describe('getDaemonPaths', () => { + it('returns correct paths for the given data directory', () => { + const dataDir = '/tmp/test-data'; + const paths = getDaemonPaths(dataDir); + + expect(paths).toStrictEqual({ + socketPath: join(dataDir, 'daemon.sock'), + pidPath: join(dataDir, 'daemon.pid'), + logPath: join(dataDir, 'daemon.log'), + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/paths.ts b/packages/wallet-cli/src/daemon/paths.ts new file mode 100644 index 00000000000..950fadc2d39 --- /dev/null +++ b/packages/wallet-cli/src/daemon/paths.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path'; + +import type { DaemonPaths } from './types'; + +/** + * Resolve paths for daemon state files within the given data directory. + * + * @param dataDir - The base data directory (e.g. oclif config.dataDir). + * @returns Resolved paths for socket, PID file, and log file. + */ +export function getDaemonPaths(dataDir: string): DaemonPaths { + return { + socketPath: join(dataDir, 'daemon.sock'), + pidPath: join(dataDir, 'daemon.pid'), + logPath: join(dataDir, 'daemon.log'), + }; +} diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts new file mode 100644 index 00000000000..29bc128640b --- /dev/null +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -0,0 +1,620 @@ +import { EventEmitter } from 'node:events'; +import { unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import type { Server, Socket } from 'node:net'; + +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcHandlerMap } from './types'; + +jest.mock('node:fs/promises'); +jest.mock('node:net'); + +const mockUnlink = jest.mocked(unlink); +const mockCreateServer = jest.mocked(createServer); + +type ConnectionCallback = (socket: Socket) => void; + +/** + * Flush pending microtasks/promises by awaiting multiple ticks. + */ +async function flushPromises(): Promise { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } +} + +/** + * Create a mock net.Server. + * + * @returns The mock server and a function to simulate incoming connections. + */ +function createMockServer(): { + server: Server; + simulateConnection: (socket: Socket) => void; +} { + const emitter = new EventEmitter(); + let connectionCallback: ConnectionCallback | undefined; + + const server = Object.assign(emitter, { + listen: jest.fn((_path: string, onListening: () => void) => { + onListening(); + }), + close: jest.fn((onClose: (closeError?: Error) => void) => { + onClose(); + }), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Server; + + mockCreateServer.mockImplementation((handler: unknown) => { + connectionCallback = handler as ConnectionCallback; + return server; + }); + + return { + server, + simulateConnection: (socket: Socket): void => { + connectionCallback?.(socket); + }, + }; +} + +/** + * Create a mock Socket. + * + * @returns A mock socket. + */ +function createMockSocket(): Socket { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + end: jest.fn(), + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; +} + +/** + * Parse the JSON-RPC response written to socket.end(). + * + * @param socket - The mock socket. + * @returns The parsed response. + */ +function getResponse(socket: Socket): Record { + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + return JSON.parse(endCall.trim()) as Record; +} + +/** + * Send a JSON-RPC request to a mock socket by emitting data. + * + * @param socket - The mock socket. + * @param request - The request object. + */ +function sendRequest(socket: Socket, request: Record): void { + socket.emit('data', Buffer.from(`${JSON.stringify(request)}\n`)); +} + +describe('startRpcSocketServer', () => { + beforeEach(() => { + mockUnlink.mockResolvedValue(undefined); + }); + + it('removes stale socket file before listening', async () => { + createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + expect(mockUnlink).toHaveBeenCalledWith('/tmp/test.sock'); + }); + + it('ignores ENOENT unlink errors for missing files', async () => { + mockUnlink.mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); + createMockServer(); + + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + expect(handle).toBeDefined(); + }); + + it('propagates non-ENOENT unlink errors', async () => { + mockUnlink.mockRejectedValue( + Object.assign(new Error('EACCES'), { code: 'EACCES' }), + ); + createMockServer(); + + await expect( + startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }), + ).rejects.toThrow('EACCES'); + }); + + it('returns a handle with close()', async () => { + const { server } = createMockServer(); + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + await handle.close(); + expect(server.close).toHaveBeenCalled(); + }); + + it('rejects close() when server.close errors', async () => { + createMockServer(); + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const { server } = createMockServer(); + (server.close as jest.Mock).mockImplementation( + (onClose: (closeError?: Error) => void) => { + onClose(new Error('close failed')); + }, + ); + + const handle2 = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + await expect(handle2.close()).rejects.toThrow('close failed'); + await handle.close(); + }); + + describe('request handling', () => { + it('dispatches valid request to handler and returns result', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + getStatus: jest.fn().mockResolvedValue({ status: 'ok' }), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: '1', + method: 'getStatus', + }); + + await flushPromises(); + + expect(getResponse(socket)).toStrictEqual({ + jsonrpc: '2.0', + id: '1', + result: { status: 'ok' }, + }); + }); + + it('returns null result when handler returns undefined', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + noop: jest.fn().mockResolvedValue(undefined), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'noop' }); + + await flushPromises(); + + expect(getResponse(socket).result).toBeNull(); + }); + + it('returns -32600 for missing method', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32600, + message: 'Invalid request: missing method', + }), + ); + }); + + it('returns -32601 for unknown method', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: '1', + method: 'nonexistent', + }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32601, + message: 'Method not found: nonexistent', + }), + ); + }); + + it('returns -32603 when handler throws an Error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue(new Error('handler failed')), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'handler failed', + }), + ); + }); + + it('passes through RPC error objects when handler throws one', async () => { + const { simulateConnection } = createMockServer(); + const rpcError = { code: -32001, message: 'custom rpc' }; + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue(rpcError), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32001, + message: 'custom rpc', + }); + }); + + it('returns Internal error when handler throws a non-Error value', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue('string error'), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'Internal error', + }), + ); + }); + + it('intercepts shutdown method and calls onShutdown', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + const onShutdown = jest.fn().mockResolvedValue(undefined); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + onShutdown, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await jest.advanceTimersByTimeAsync(0); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + expect(onShutdown).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('handles onShutdown rejection and logs to stderr', async () => { + jest.useFakeTimers(); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const { simulateConnection } = createMockServer(); + const onShutdown = jest.fn().mockRejectedValue(new Error('shutdown err')); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + onShutdown, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await jest.advanceTimersByTimeAsync(0); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('onShutdown callback failed'), + ); + jest.useRealTimers(); + stderrSpy.mockRestore(); + }); + + it('responds to shutdown even without onShutdown callback', async () => { + const { simulateConnection } = createMockServer(); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await flushPromises(); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + }); + + it('rejects multiple requests per connection', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + // Send a valid request followed by extra data after the newline. + socket.emit( + 'data', + Buffer.from( + `${JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'a' })}\nextra`, + ), + ); + + const response = getResponse(socket); + expect(response.error).toStrictEqual( + expect.objectContaining({ + code: -32600, + message: 'Only one request per connection is allowed', + }), + ); + }); + + it('accumulates partial data across multiple events', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + test: jest.fn().mockResolvedValue('ok'), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + const full = JSON.stringify({ + jsonrpc: '2.0', + id: '1', + method: 'test', + }); + socket.emit('data', Buffer.from(full.slice(0, 10))); + socket.emit('data', Buffer.from(`${full.slice(10)}\n`)); + + await flushPromises(); + + expect(getResponse(socket).result).toBe('ok'); + }); + + it('silently ignores EPIPE and ECONNRESET socket errors', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + socket.emit( + 'error', + Object.assign(new Error('broken pipe'), { code: 'EPIPE' }), + ); + socket.emit( + 'error', + Object.assign(new Error('reset'), { code: 'ECONNRESET' }), + ); + + expect(stderrSpy).not.toHaveBeenCalled(); + stderrSpy.mockRestore(); + }); + + it('logs unexpected socket errors to stderr', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + socket.emit( + 'error', + Object.assign(new Error('unexpected'), { code: 'ENOMEM' }), + ); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Unexpected socket error'), + ); + stderrSpy.mockRestore(); + }); + + it('sends internal error when response serialization fails', async () => { + const { simulateConnection } = createMockServer(); + const circular: Record = {}; + circular.self = circular; + const handlers: RpcHandlerMap = { + bad: jest.fn().mockResolvedValue(circular), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'bad' }); + + await flushPromises(); + + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + const response = JSON.parse(endCall.trim()) as Record; + expect(response.error).toBeDefined(); + }); + + it('handles invalid JSON gracefully', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + socket.emit('data', Buffer.from('not-json\n')); + + await flushPromises(); + + expect((getResponse(socket).error as { code: number }).code).toBe(-32700); + }); + + it('uses null id when request has no id', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', method: 'unknown' }); + + await flushPromises(); + + expect(getResponse(socket).id).toBeNull(); + }); + + it('destroys socket when no complete request arrives within timeout', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + // Send partial data (no newline). + socket.emit('data', Buffer.from('partial')); + + expect(socket.destroy).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(socket.destroy).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('wraps thrown object with code but no message as internal error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue({ code: 42 }), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'Internal error', + }), + ); + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts new file mode 100644 index 00000000000..5e00290e745 --- /dev/null +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -0,0 +1,250 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcResponse } from '@metamask/utils'; +import { hasProperty } from '@metamask/utils'; +import { unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import type { Server } from 'node:net'; + +import type { RpcHandlerMap } from './types'; +import { isErrorWithCode } from './utils'; + +const CONNECTION_TIMEOUT_MS = 30_000; + +/** + * Handle returned by {@link startRpcSocketServer}. + */ +export type RpcSocketServerHandle = { + close: () => Promise; +}; + +/** + * Start a Unix socket server that processes JSON-RPC requests. + * + * Each connection reads one newline-delimited JSON-RPC request, processes it + * via the provided handler map, writes a JSON-RPC response, and closes. + * + * The special `shutdown` method is intercepted before handler dispatch and + * triggers the provided {@link onShutdown} callback after responding. + * + * @param options - Server options. + * @param options.socketPath - The Unix socket path to listen on. + * @param options.handlers - Map of RPC method names to handler functions. + * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. + * @returns A handle with a `close()` function for cleanup. + */ +export async function startRpcSocketServer({ + socketPath, + handlers, + onShutdown, +}: { + socketPath: string; + handlers: RpcHandlerMap; + onShutdown?: (() => Promise) | undefined; +}): Promise { + const server = createServer((socket) => { + let buffer = ''; + + // Destroy connections that never send a complete request line. + const timer = setTimeout(() => { + socket.destroy(); + }, CONNECTION_TIMEOUT_MS); + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx === -1) { + return; + } + + clearTimeout(timer); + + // One request per connection. + socket.removeListener('data', onData); + + const line = buffer.slice(0, idx); + const remaining = buffer.slice(idx + 1); + buffer = ''; + + if (remaining.length > 0) { + socket.end( + `${JSON.stringify({ + jsonrpc: '2.0', + error: rpcErrors + .invalidRequest({ + message: 'Only one request per connection is allowed', + }) + .serialize(), + })}\n`, + ); + return; + } + + handleRequest(handlers, line, onShutdown) + .then((response) => { + socket.end(`${JSON.stringify(response)}\n`); + return undefined; + }) + .catch(() => { + socket.end( + `${JSON.stringify({ + jsonrpc: '2.0', + error: rpcErrors + .internal({ message: 'Internal error' }) + .serialize(), + })}\n`, + ); + }); + }; + socket.on('data', onData); + + socket.on('error', (socketError: NodeJS.ErrnoException) => { + const { code } = socketError; + if (code === 'EPIPE' || code === 'ECONNRESET') { + return; // Expected during probe/disconnect. + } + process.stderr.write(`Unexpected socket error: ${String(socketError)}\n`); + }); + }); + + await listen(server, socketPath); + + return { + close: async (): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + }; +} + +/** + * Handle a single JSON-RPC request line, intercepting the `shutdown` method. + * + * @param handlers - The RPC handler map. + * @param line - The raw JSON line from the socket. + * @param onShutdown - Optional shutdown callback. + * @returns A JSON-RPC response object. + */ +async function handleRequest( + handlers: RpcHandlerMap, + line: string, + onShutdown?: () => Promise, +): Promise { + type JsonRpcId = string | number | null; + let id: JsonRpcId = null; + let request: { id?: unknown; method?: string; params?: unknown }; + + try { + request = JSON.parse(line) as typeof request; + } catch { + return { + jsonrpc: '2.0', + id: null, + error: rpcErrors.parse({ message: 'Parse error' }).serialize(), + }; + } + + id = (request.id ?? null) as JsonRpcId; + + try { + const { method } = request; + + if (typeof method !== 'string') { + return { + jsonrpc: '2.0', + id, + error: rpcErrors + .invalidRequest({ message: 'Invalid request: missing method' }) + .serialize(), + }; + } + + // Intercept shutdown before handler dispatch. + if (method === 'shutdown') { + if (onShutdown) { + setTimeout(() => { + onShutdown().catch((error: unknown) => { + process.stderr.write( + `onShutdown callback failed: ${String(error)}\n`, + ); + }); + }, 0); + } + return { jsonrpc: '2.0', id, result: { status: 'shutting down' } }; + } + + const handler = handlers[method]; + if (!handler) { + return { + jsonrpc: '2.0', + id, + error: rpcErrors + .methodNotFound({ message: `Method not found: ${method}` }) + .serialize(), + }; + } + + const params = (request.params as Parameters[0]) ?? null; + const result = await handler(params); + return { jsonrpc: '2.0', id, result: result ?? null }; + } catch (error) { + if (isRpcError(error)) { + return { jsonrpc: '2.0', id, error }; + } + const message = error instanceof Error ? error.message : 'Internal error'; + return { + jsonrpc: '2.0', + id, + error: rpcErrors.internal({ message }).serialize(), + }; + } +} + +/** + * Check if an error is an RPC error with a numeric code. + * + * @param error - The error to check. + * @returns True if the error has a numeric code property. + */ +function isRpcError( + error: unknown, +): error is { code: number; message: string } { + return ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'code') && + typeof error.code === 'number' && + hasProperty(error, 'message') && + typeof error.message === 'string' + ); +} + +/** + * Start listening on a Unix socket path, removing any stale socket file. + * + * @param server - The net.Server instance. + * @param socketPath - The Unix socket path. + */ +async function listen(server: Server, socketPath: string): Promise { + try { + await unlink(socketPath); + } catch (error) { + if (!isErrorWithCode(error, 'ENOENT')) { + throw error; + } + } + + return new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(socketPath, () => { + server.removeListener('error', reject); + resolve(); + }); + }); +} diff --git a/packages/wallet-cli/src/daemon/socket-line.test.ts b/packages/wallet-cli/src/daemon/socket-line.test.ts new file mode 100644 index 00000000000..b91e4b97245 --- /dev/null +++ b/packages/wallet-cli/src/daemon/socket-line.test.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from 'node:events'; +import type { Socket } from 'node:net'; + +import { readLine, writeLine } from './socket-line'; + +/** + * Create a mock Socket backed by EventEmitter. + * + * @returns A mock socket. + */ +function createMockSocket(): Socket { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + write: jest.fn(), + destroy: jest.fn(), + }); + return socket as unknown as Socket; +} + +describe('writeLine', () => { + it('writes the line with a trailing newline', async () => { + const socket = createMockSocket(); + (socket.write as jest.Mock).mockImplementation( + (_data: string, callback: (writeError?: Error) => void) => callback(), + ); + + await writeLine(socket, 'hello'); + expect(socket.write).toHaveBeenCalledWith('hello\n', expect.any(Function)); + }); + + it('rejects when socket.write returns an error', async () => { + const socket = createMockSocket(); + const writeError = new Error('write failed'); + (socket.write as jest.Mock).mockImplementation( + (_data: string, callback: (e?: Error) => void) => callback(writeError), + ); + + await expect(writeLine(socket, 'hello')).rejects.toThrow('write failed'); + }); +}); + +describe('readLine', () => { + it('resolves with the line when data contains a newline', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hello\n')); + expect(await promise).toBe('hello'); + }); + + it('accumulates data across multiple events', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hel')); + socket.emit('data', Buffer.from('lo\n')); + expect(await promise).toBe('hello'); + }); + + it('rejects on socket error', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('error', new Error('socket error')); + await expect(promise).rejects.toThrow('socket error'); + }); + + it('rejects on socket end', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('end'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects on socket close', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('close'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects after timeout when no complete line received', async () => { + jest.useFakeTimers(); + const socket = createMockSocket(); + const promise = readLine(socket, 500); + + jest.advanceTimersByTime(500); + await expect(promise).rejects.toThrow('Socket read timed out'); + jest.useRealTimers(); + }); + + it('resolves before timeout when data arrives in time', async () => { + jest.useFakeTimers(); + const socket = createMockSocket(); + const promise = readLine(socket, 5000); + + socket.emit('data', Buffer.from('hello\n')); + expect(await promise).toBe('hello'); + jest.useRealTimers(); + }); + + it('cleans up listeners after resolving', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hello\n')); + await promise; + + expect(socket.listenerCount('data')).toBe(0); + expect(socket.listenerCount('error')).toBe(0); + expect(socket.listenerCount('end')).toBe(0); + expect(socket.listenerCount('close')).toBe(0); + }); +}); diff --git a/packages/wallet-cli/src/daemon/socket-line.ts b/packages/wallet-cli/src/daemon/socket-line.ts new file mode 100644 index 00000000000..c061174fb83 --- /dev/null +++ b/packages/wallet-cli/src/daemon/socket-line.ts @@ -0,0 +1,86 @@ +import type { Socket } from 'node:net'; + +/** + * Write a newline-delimited line to a socket. + * + * @param socket - The socket to write to. + * @param line - The line to write (without trailing newline). + */ +export async function writeLine(socket: Socket, line: string): Promise { + return new Promise((resolve, reject) => { + socket.write(`${line}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * Read a single newline-delimited line from a socket. + * + * @param socket - The socket to read from. + * @param timeoutMs - Optional timeout in milliseconds. Rejects with a timeout + * error if no complete line is received within the limit. + * @returns The line read (without trailing newline). + */ +export async function readLine( + socket: Socket, + timeoutMs?: number, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + let timer: ReturnType | undefined; + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error('Socket read timed out')); + }, timeoutMs); + } + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + cleanup(); + resolve(buffer.slice(0, idx)); + } + }; + + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + + const onEnd = (): void => { + cleanup(); + reject(new Error('Socket closed before response received')); + }; + + const onClose = (): void => { + cleanup(); + reject(new Error('Socket closed before response received')); + }; + + /** + * Remove listeners registered by this call and clear the timeout. + */ + function cleanup(): void { + if (timer !== undefined) { + clearTimeout(timer); + } + socket.removeListener('data', onData); + socket.removeListener('error', onError); + socket.removeListener('end', onEnd); + socket.removeListener('close', onClose); + } + + socket.on('data', onData); + socket.once('error', onError); + socket.once('end', onEnd); + socket.once('close', onClose); + }); +} diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts new file mode 100644 index 00000000000..48e276eeed7 --- /dev/null +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -0,0 +1,204 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon, sendCommand } from './daemon-client'; +import { stopDaemon } from './stop-daemon'; +import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; + +jest.mock('node:fs/promises'); +jest.mock('./daemon-client'); +jest.mock('./utils'); + +const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockSendCommand = jest.mocked(sendCommand); +const mockReadPidFile = jest.mocked(readPidFile); +const mockIsProcessAlive = jest.mocked(isProcessAlive); +const mockSendSignal = jest.mocked(sendSignal); +const mockWaitFor = jest.mocked(waitFor); + +describe('stopDaemon', () => { + beforeEach(() => { + mockRm.mockResolvedValue(undefined); + }); + + it('returns true when daemon is not running (no PID file)', async () => { + mockReadPidFile.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('cleans up stale PID file when daemon is not running', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(false); + mockPingDaemon.mockResolvedValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.pid', { force: true }); + }); + + it('stops daemon via graceful RPC shutdown', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { status: 'shutting down' }, + }); + // Invoke the check callback for coverage, then return true + mockWaitFor.mockImplementation(async (check) => { + await check(); + return true; + }); + + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); + + expect(result).toBe(true); + expect(mockSendCommand).toHaveBeenCalledWith({ + socketPath: '/tmp/test.sock', + method: 'shutdown', + }); + expect(log).toHaveBeenCalledWith('Stopping daemon...'); + expect(log).toHaveBeenCalledWith('Daemon stopped.'); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.pid', { force: true }); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.sock', { force: true }); + }); + + it('falls through to SIGTERM when graceful shutdown times out', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + mockSendSignal.mockReturnValue(true); + // First waitFor (graceful) invokes cb and fails, second (SIGTERM) invokes cb and succeeds + mockWaitFor + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return true; + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGTERM'); + }); + + it('falls through to SIGKILL when SIGTERM times out', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + mockSendSignal.mockReturnValue(true); + // All three waitFor calls invoke check, graceful + SIGTERM fail, SIGKILL succeeds + mockWaitFor + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return true; + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('returns false when all strategies fail', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + mockSendSignal.mockReturnValue(true); + mockWaitFor.mockResolvedValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(false); + }); + + it('treats ESRCH on SIGTERM as stopped', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + mockSendSignal.mockReturnValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('treats ESRCH on SIGKILL as stopped', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + // SIGTERM signal sent but process doesn't die, SIGKILL finds it gone + mockSendSignal.mockReturnValueOnce(true).mockReturnValueOnce(false); + mockWaitFor.mockResolvedValueOnce(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('falls through to SIGKILL when SIGTERM throws EPERM', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + mockSendSignal + .mockImplementationOnce(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }) + .mockReturnValueOnce(true); + mockWaitFor.mockResolvedValueOnce(true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('returns false when both SIGTERM and SIGKILL throw EPERM', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + mockSendSignal.mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(false); + }); + + it('treats sendCommand error as socket unresponsive', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockRejectedValue(new Error('socket error')); + mockWaitFor.mockResolvedValue(true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); +}); diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts new file mode 100644 index 00000000000..dc5f0c27901 --- /dev/null +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -0,0 +1,80 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon, sendCommand } from './daemon-client'; +import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; + +/** + * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if + * the socket is unresponsive, and escalates to SIGKILL if SIGTERM is ignored. + * + * @param socketPath - The daemon socket path. + * @param pidPath - The daemon PID file path. + * @param log - Optional logging function for status messages. + * @returns True if the daemon was stopped (or was not running). + */ +export async function stopDaemon( + socketPath: string, + pidPath: string, + log?: (message: string) => void, +): Promise { + const pid = await readPidFile(pidPath); + const processAlive = pid !== undefined && isProcessAlive(pid); + const socketResponsive = await pingDaemon(socketPath); + + if (!socketResponsive && !processAlive) { + if (pid !== undefined) { + await rm(pidPath, { force: true }); + } + return true; + } + + log?.('Stopping daemon...'); + + let stopped = false; + + // Strategy 1: Graceful socket-based shutdown. + if (socketResponsive) { + try { + await sendCommand({ socketPath, method: 'shutdown' }); + } catch (error) { + log?.(`Graceful shutdown request failed: ${String(error)}`); + } + stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); + } + + // Strategy 2: SIGTERM. + if (!stopped && pid !== undefined) { + try { + if (sendSignal(pid, 'SIGTERM')) { + stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + } else { + stopped = true; // Process already gone (ESRCH). + } + } catch (error) { + log?.(`SIGTERM failed: ${String(error)}`); + } + } + + // Strategy 3: SIGKILL. + if (!stopped && pid !== undefined) { + try { + if (sendSignal(pid, 'SIGKILL')) { + stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + } else { + stopped = true; // Process already gone (ESRCH). + } + } catch (error) { + log?.(`SIGKILL failed: ${String(error)}`); + } + } + + if (stopped) { + await Promise.all([ + rm(pidPath, { force: true }), + rm(socketPath, { force: true }), + ]); + log?.('Daemon stopped.'); + } + + return stopped; +} diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts new file mode 100644 index 00000000000..4d2793cb23c --- /dev/null +++ b/packages/wallet-cli/src/daemon/types.ts @@ -0,0 +1,41 @@ +import type { Json } from '@metamask/utils'; + +/** + * A function that handles a JSON-RPC method call. + * + * The `params` argument will be `null` if the client did not provide params. + */ +export type RpcHandler = (params: Json) => Promise; + +/** + * A map of RPC method names to their handler functions. + */ +export type RpcHandlerMap = Record; + +/** + * Resolved paths for daemon state files. + */ +export type DaemonPaths = { + socketPath: string; + pidPath: string; + logPath: string; +}; + +/** + * Status information returned by the daemon's `getStatus` RPC method. + */ +export type DaemonStatusInfo = { + pid: number; + uptime: number; +}; + +/** + * Configuration passed to the daemon spawner. + */ +export type DaemonSpawnConfig = { + dataDir: string; + infuraProjectId: string; + password: string; + srp: string; + packageRoot: string; +}; diff --git a/packages/wallet-cli/src/daemon/utils.test.ts b/packages/wallet-cli/src/daemon/utils.test.ts new file mode 100644 index 00000000000..78b678078f4 --- /dev/null +++ b/packages/wallet-cli/src/daemon/utils.test.ts @@ -0,0 +1,159 @@ +import { readFile } from 'node:fs/promises'; + +import { + isErrorWithCode, + isProcessAlive, + readPidFile, + sendSignal, + waitFor, +} from './utils'; + +jest.mock('node:fs/promises'); + +const mockReadFile = jest.mocked(readFile); + +describe('isErrorWithCode', () => { + it('returns true for an Error with a matching code', () => { + const error = Object.assign(new Error('fail'), { code: 'ENOENT' }); + expect(isErrorWithCode(error, 'ENOENT')).toBe(true); + }); + + it('returns false for an Error with a different code', () => { + const error = Object.assign(new Error('fail'), { code: 'EPERM' }); + expect(isErrorWithCode(error, 'ENOENT')).toBe(false); + }); + + it('returns false for an Error without a code', () => { + expect(isErrorWithCode(new Error('fail'), 'ENOENT')).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isErrorWithCode('not an error', 'ENOENT')).toBe(false); + expect(isErrorWithCode(null, 'ENOENT')).toBe(false); + expect(isErrorWithCode(undefined, 'ENOENT')).toBe(false); + }); +}); + +describe('readPidFile', () => { + it('returns the PID number from a valid file', async () => { + mockReadFile.mockResolvedValue('12345'); + expect(await readPidFile('/tmp/test.pid')).toBe(12345); + }); + + it('returns undefined for ENOENT', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('not found'), { code: 'ENOENT' }), + ); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for NaN content', async () => { + mockReadFile.mockResolvedValue('not-a-number'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for zero', async () => { + mockReadFile.mockResolvedValue('0'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for negative numbers', async () => { + mockReadFile.mockResolvedValue('-1'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('rethrows non-ENOENT errors', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + await expect(readPidFile('/tmp/test.pid')).rejects.toThrow( + 'permission denied', + ); + }); +}); + +describe('isProcessAlive', () => { + it('returns true when process.kill(pid, 0) succeeds', () => { + jest.spyOn(process, 'kill').mockImplementation(() => true); + expect(isProcessAlive(123)).toBe(true); + }); + + it('returns true on EPERM (process exists but no permission)', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + expect(isProcessAlive(123)).toBe(true); + }); + + it('returns false on other errors', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('esrch'), { code: 'ESRCH' }); + }); + expect(isProcessAlive(123)).toBe(false); + }); +}); + +describe('sendSignal', () => { + it('returns true when signal is delivered', () => { + jest.spyOn(process, 'kill').mockImplementation(() => true); + expect(sendSignal(123, 'SIGTERM')).toBe(true); + }); + + it('returns false on ESRCH (process gone)', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('esrch'), { code: 'ESRCH' }); + }); + expect(sendSignal(123, 'SIGTERM')).toBe(false); + }); + + it('rethrows other errors', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + expect(() => sendSignal(123, 'SIGTERM')).toThrow('eperm'); + }); +}); + +describe('waitFor', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns true when check passes immediately', async () => { + expect(await waitFor(() => true, 1000)).toBe(true); + }); + + it('returns true when check passes after polling', async () => { + let calls = 0; + const check = (): boolean => { + calls += 1; + return calls >= 3; + }; + + const promise = waitFor(check, 5000); + await jest.advanceTimersByTimeAsync(500); + expect(await promise).toBe(true); + }); + + it('returns false on timeout', async () => { + const promise = waitFor(() => false, 500); + await jest.advanceTimersByTimeAsync(750); + expect(await promise).toBe(false); + }); + + it('works with async check functions', async () => { + let calls = 0; + const check = async (): Promise => { + calls += 1; + return calls >= 2; + }; + + const promise = waitFor(check, 5000); + await jest.advanceTimersByTimeAsync(500); + expect(await promise).toBe(true); + }); +}); diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts new file mode 100644 index 00000000000..3e1db9cdc04 --- /dev/null +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -0,0 +1,96 @@ +import { hasProperty } from '@metamask/utils'; +import { readFile } from 'node:fs/promises'; + +/** + * Check whether an unknown error is a Node.js system error with the given code. + * + * @param error - The error to check. + * @param code - The expected error code (e.g. 'ENOENT', 'EPERM'). + * @returns True if the error matches the code. + */ +export function isErrorWithCode(error: unknown, code: string): boolean { + return ( + // TODO: use Error.isError() + error instanceof Error && hasProperty(error, 'code') && error.code === code + ); +} + +/** + * Read a PID from a file. + * + * @param pidPath - The PID file path. + * @returns The PID, or undefined if the file is missing or invalid. + */ +export async function readPidFile( + pidPath: string, +): Promise { + try { + const pid = Number(await readFile(pidPath, 'utf-8')); + return pid > 0 && !Number.isNaN(pid) ? pid : undefined; + } catch (error: unknown) { + if (isErrorWithCode(error, 'ENOENT')) { + return undefined; + } + throw error; + } +} + +/** + * Check whether a process is alive by sending signal 0. + * + * @param pid - The process ID to check. + * @returns True if the process exists. + */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + if (isErrorWithCode(error, 'EPERM')) { + return true; + } + return false; + } +} + +/** + * Send a signal to a process. Returns true if the signal was sent, false if + * the process does not exist (ESRCH). Re-throws on permission errors and + * other failures. + * + * @param pid - The process ID. + * @param signal - The signal to send. + * @returns True if the signal was delivered, false if the process is gone. + */ +export function sendSignal(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch (error: unknown) { + if (isErrorWithCode(error, 'ESRCH')) { + return false; + } + throw error; + } +} + +/** + * Poll until a condition is met or the timeout elapses. + * + * @param check - A function that returns true when the condition is met. + * @param timeoutMs - Maximum time to wait in milliseconds. + * @returns True if the condition was met, false on timeout. + */ +export async function waitFor( + check: () => boolean | Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return false; +} diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts new file mode 100644 index 00000000000..16898475912 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -0,0 +1,50 @@ +import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; + +import { createWallet } from './wallet-factory'; + +jest.mock('@metamask/wallet'); +jest.mock('@metamask/remote-feature-flag-controller'); + +const MockWallet = jest.mocked(Wallet); +const mockImportSrp = jest.mocked(importSecretRecoveryPhrase); + +const CONFIG = { + infuraProjectId: 'test-key', + password: 'test-pass', + srp: 'test test test test test test test test test test test ball', +}; + +describe('createWallet', () => { + it('instantiates Wallet with the given infuraProjectId', async () => { + await createWallet(CONFIG); + + expect(MockWallet).toHaveBeenCalledTimes(1); + const args = MockWallet.mock.calls[0][0]; + expect(args.options.infuraProjectId).toBe('test-key'); + }); + + it('uses expected default options', async () => { + await createWallet(CONFIG); + + const args = MockWallet.mock.calls[0][0]; + expect(args.options.clientVersion).toBe('0.0.0'); + expect(args.options.showApprovalRequest()).toBeUndefined(); + expect(args.options.getMetaMetricsId()).toBe('cli'); + expect(args.options.clientConfigApiService).toBeDefined(); + }); + + it('imports the secret recovery phrase with the given password', async () => { + await createWallet(CONFIG); + + expect(mockImportSrp).toHaveBeenCalledWith( + expect.any(Wallet), + 'test-pass', + 'test test test test test test test test test test test ball', + ); + }); + + it('returns the wallet instance', async () => { + const wallet = await createWallet(CONFIG); + expect(wallet).toBeInstanceOf(Wallet); + }); +}); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts new file mode 100644 index 00000000000..7707d73a672 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -0,0 +1,48 @@ +import { + ClientConfigApiService, + ClientType, + DistributionType, + EnvironmentType, +} from '@metamask/remote-feature-flag-controller'; +import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; + +/** + * Create a configured Wallet instance for daemon use. + * + * @param config - Wallet configuration. + * @param config.infuraProjectId - The Infura project ID for network access. + * @param config.password - The wallet password. + * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). + * @returns A new Wallet instance with the SRP imported. + */ +export async function createWallet({ + infuraProjectId, + password, + srp, +}: { + infuraProjectId: string; + password: string; + srp: string; +}): Promise { + const wallet = new Wallet({ + options: { + infuraProjectId, + clientVersion: '0.0.0', + // TODO: Implement showApprovalRequest + showApprovalRequest: (): undefined => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: (): string => 'cli', + }, + }); + + await importSecretRecoveryPhrase(wallet, password, srp); + + return wallet; +} diff --git a/packages/wallet-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json new file mode 100644 index 00000000000..ac3df52090a --- /dev/null +++ b/packages/wallet-cli/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../wallet/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json new file mode 100644 index 00000000000..7eb92377c96 --- /dev/null +++ b/packages/wallet-cli/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../remote-feature-flag-controller/tsconfig.json" + }, + { + "path": "../wallet/tsconfig.json" + } + ], + "include": ["../../types", "./bin", "./src"] +} diff --git a/packages/wallet-cli/typedoc.json b/packages/wallet-cli/typedoc.json new file mode 100644 index 00000000000..cb2d25b4bbb --- /dev/null +++ b/packages/wallet-cli/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/daemon/daemon-client.ts", "./src/daemon/types.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index a3db3b1b449..3b1b41def3b 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1 +1,2 @@ export { Wallet } from './Wallet'; +export { importSecretRecoveryPhrase } from './utilities'; diff --git a/teams.json b/teams.json index b2648bc9e8d..4f9d0e96281 100644 --- a/teams.json +++ b/teams.json @@ -75,5 +75,7 @@ "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", "metamask/storage-service": "team-extension-platform,team-mobile-platform", "metamask/config-registry-controller": "team-networks", - "metamask/money-account-controller": "team-accounts-framework" + "metamask/money-account-controller": "team-accounts-framework", + "metamask/wallet": "team-core-platform", + "metamask/wallet-cli": "team-core-platform" } diff --git a/tsconfig.build.json b/tsconfig.build.json index af5eef1e9a2..16137b2cd77 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -238,6 +238,9 @@ { "path": "./packages/user-operation-controller/tsconfig.build.json" }, + { + "path": "./packages/wallet-cli/tsconfig.build.json" + }, { "path": "./packages/wallet/tsconfig.build.json" } diff --git a/tsconfig.json b/tsconfig.json index cd7ce6f2538..9d1557261df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -229,6 +229,9 @@ }, { "path": "./packages/wallet" + }, + { + "path": "./packages/wallet-cli" } ], "files": [], diff --git a/yarn.config.cjs b/yarn.config.cjs index 78612e4699e..3f7411e2060 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -119,7 +119,8 @@ module.exports = defineConfig({ // exports correctly. if ( workspace.ident !== '@metamask/foundryup' && - workspace.ident !== '@metamask/messenger-cli' + workspace.ident !== '@metamask/messenger-cli' && + workspace.ident !== '@metamask/wallet-cli' ) { expectCorrectWorkspaceExports(workspace); } diff --git a/yarn.lock b/yarn.lock index 60655666277..de9f28b10d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1792,10 +1792,10 @@ __metadata: languageName: node linkType: hard -"@inquirer/ansi@npm:^2.0.3": - version: 2.0.3 - resolution: "@inquirer/ansi@npm:2.0.3" - checksum: 10/846bf48acc4a89e62c2be49af74c014311e5a575a46875fe3066c28ac241671132fb16518e859bd63db6a2be326a6a7160c9a79f60013b1a2c6a1c1d620a98ac +"@inquirer/ansi@npm:^2.0.3, @inquirer/ansi@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/ansi@npm:2.0.5" + checksum: 10/482f8a606885ee0377a60eb5e9b303ae75fcfb2c6250819be348047c89e4e01a25feef369d3646dec7ba17e38cd5cc08271db6db21c401be315b3ada749e6b53 languageName: node linkType: hard @@ -1827,18 +1827,18 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:^6.0.5": - version: 6.0.7 - resolution: "@inquirer/confirm@npm:6.0.7" +"@inquirer/confirm@npm:^6.0.11, @inquirer/confirm@npm:^6.0.5": + version: 6.0.11 + resolution: "@inquirer/confirm@npm:6.0.11" dependencies: - "@inquirer/core": "npm:^11.1.4" - "@inquirer/type": "npm:^4.0.3" + "@inquirer/core": "npm:^11.1.8" + "@inquirer/type": "npm:^4.0.5" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/c7da13527dd46f09515f68f1a809791eb8fc349422a39d9fd4c4254082edff3f8c9ffda4c86d10f1b80ebdbed34d2bc2c86c2275096be3d4559a35a88955da41 + checksum: 10/f51ead4a6a68ac585257e66bbe8196a6b7aec1956b12038827a2d03a509b9db8e0ece97d4b92033259090de33d9aefd0cff288cd4dce6f472d927ef8fe9302f5 languageName: node linkType: hard @@ -1859,13 +1859,13 @@ __metadata: languageName: node linkType: hard -"@inquirer/core@npm:^11.1.4": - version: 11.1.4 - resolution: "@inquirer/core@npm:11.1.4" +"@inquirer/core@npm:^11.1.4, @inquirer/core@npm:^11.1.8": + version: 11.1.8 + resolution: "@inquirer/core@npm:11.1.8" dependencies: - "@inquirer/ansi": "npm:^2.0.3" - "@inquirer/figures": "npm:^2.0.3" - "@inquirer/type": "npm:^4.0.3" + "@inquirer/ansi": "npm:^2.0.5" + "@inquirer/figures": "npm:^2.0.5" + "@inquirer/type": "npm:^4.0.5" cli-width: "npm:^4.1.0" fast-wrap-ansi: "npm:^0.2.0" mute-stream: "npm:^3.0.0" @@ -1875,7 +1875,7 @@ __metadata: peerDependenciesMeta: "@types/node": optional: true - checksum: 10/e022de7f3b9b65b7ae98b5316b205ec59c84294936fd63f57b37673f2d334dd8b762c50e8d3edf59caac72d6bed86e533fa88e87f3e271a3275c0a1f6acf0271 + checksum: 10/e034f637ea9c12c2aaf8f5b128611f9d72976b50cf387f1207e0459342924c64f2de7e675e2b86616c44daf05700c4764f83e7ca1417aa41ed3d29d458062218 languageName: node linkType: hard @@ -1925,10 +1925,10 @@ __metadata: languageName: node linkType: hard -"@inquirer/figures@npm:^2.0.3": - version: 2.0.3 - resolution: "@inquirer/figures@npm:2.0.3" - checksum: 10/d1496081e28e8fb5f50790fdbf7fb5f437933de557092a3eaac7f290190f9597ff9d75693bbb6d94e3da71b4dae567f2e88d3c54557b280b7b832219ff26e072 +"@inquirer/figures@npm:^2.0.3, @inquirer/figures@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/figures@npm:2.0.5" + checksum: 10/e4d09c11a75206578abcfd8fc69b0f54cff7a853826696df5b3a45ed24ebc5c82e8998f1e9fa42119de848e6a0a526a6ac476053800413637bf6d21c2116cc60 languageName: node linkType: hard @@ -2059,15 +2059,15 @@ __metadata: languageName: node linkType: hard -"@inquirer/type@npm:^4.0.3": - version: 4.0.3 - resolution: "@inquirer/type@npm:4.0.3" +"@inquirer/type@npm:^4.0.3, @inquirer/type@npm:^4.0.5": + version: 4.0.5 + resolution: "@inquirer/type@npm:4.0.5" peerDependencies: "@types/node": ">=18" peerDependenciesMeta: "@types/node": optional: true - checksum: 10/93166fc35dbb597067341c125090a51b1c8d0d57308ec14ad601727513d0aefa33643cff28aa52e7e6796a866bb2f60b4e49820774970110fe8454a276ac7c4c + checksum: 10/83d15e11cc0586373070e8c262f69b1d1e4a6c72f58b3afb3d163479309f5a9bb584320eec2d85474506fb845a114e2c50010758fcf3af56c93293d579f76333 languageName: node linkType: hard @@ -5677,7 +5677,32 @@ __metadata: languageName: node linkType: hard -"@metamask/wallet@workspace:packages/wallet": +"@metamask/wallet-cli@workspace:packages/wallet-cli": + version: 0.0.0-use.local + resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" + dependencies: + "@inquirer/confirm": "npm:^6.0.11" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/remote-feature-flag-controller": "npm:^4.2.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + "@metamask/wallet": "npm:^0.0.0" + "@oclif/core": "npm:^4.10.5" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + bin: + mm: ./bin/run.mjs + languageName: unknown + linkType: soft + +"@metamask/wallet@npm:^0.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: @@ -5956,6 +5981,32 @@ __metadata: languageName: node linkType: hard +"@oclif/core@npm:^4.10.5": + version: 4.10.5 + resolution: "@oclif/core@npm:4.10.5" + dependencies: + ansi-escapes: "npm:^4.3.2" + ansis: "npm:^3.17.0" + clean-stack: "npm:^3.0.1" + cli-spinners: "npm:^2.9.2" + debug: "npm:^4.4.3" + ejs: "npm:^3.1.10" + get-package-type: "npm:^0.1.0" + indent-string: "npm:^4.0.0" + is-wsl: "npm:^2.2.0" + lilconfig: "npm:^3.1.3" + minimatch: "npm:^10.2.5" + semver: "npm:^7.7.3" + string-width: "npm:^4.2.3" + supports-color: "npm:^8" + tinyglobby: "npm:^0.2.14" + widest-line: "npm:^3.1.0" + wordwrap: "npm:^1.0.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10/c92960f4975675cb8144cf466047be0ffb91fcbe42c0329037f21ab79e61bbd33afb7aacc2d9e1f6293f32af0460941dcaa76166f20505308a9f321baed78b30 + languageName: node + linkType: hard + "@open-rpc/meta-schema@npm:^1.14.6, @open-rpc/meta-schema@npm:^1.14.9": version: 1.14.9 resolution: "@open-rpc/meta-schema@npm:1.14.9" @@ -7185,7 +7236,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.2.1": +"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -7238,6 +7289,13 @@ __metadata: languageName: node linkType: hard +"ansis@npm:^3.17.0": + version: 3.17.0 + resolution: "ansis@npm:3.17.0" + checksum: 10/6fd6bc4d1187b894d9706f4c141c81b788e90766426617385486dae38f8b2f5a1726d8cc754939e44265f92a9db4647d5136cb1425435c39ac42b35e3acf4f3d + languageName: node + linkType: hard + "anymatch@npm:^3.0.3": version: 3.1.3 resolution: "anymatch@npm:3.1.3" @@ -7338,6 +7396,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.6": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10/cb6e0561a3c01c4b56a799cc8bab6ea5fef45f069ab32500b6e19508db270ef2dffa55e5aed5865c5526e9907b1f8be61b27530823b411ffafb5e1538c86c368 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -7459,6 +7524,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + "bare-events@npm:^2.2.0": version: 2.4.2 resolution: "bare-events@npm:2.4.2" @@ -7614,6 +7686,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.5": + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/f259b2ddf04489da9512ad637ba6b4ef2d77abd4445d20f7f1714585f153435200a53fa6a2e4a5ee974df14ddad4cd16421f6f803e96e8b452bd48598878d0ee + languageName: node + linkType: hard + "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -7907,7 +7988,16 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.6.0": +"clean-stack@npm:^3.0.1": + version: 3.0.1 + resolution: "clean-stack@npm:3.0.1" + dependencies: + escape-string-regexp: "npm:4.0.0" + checksum: 10/dc18c842d7792dd72d463936b1b0a5b2621f0fc11588ee48b602e1a29b6c010c606d89f3de1f95d15d72de74aea93c0fbac8246593a31d95f8462cac36148e05 + languageName: node + linkType: hard + +"cli-spinners@npm:^2.6.0, cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 10/a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794 @@ -8583,6 +8673,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.10": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.73": version: 1.5.79 resolution: "electron-to-chromium@npm:1.5.79" @@ -8829,6 +8930,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:4.0.0, escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10/98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 + languageName: node + linkType: hard + "escape-string-regexp@npm:^2.0.0": version: 2.0.0 resolution: "escape-string-regexp@npm:2.0.0" @@ -8836,13 +8944,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 10/98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 - languageName: node - linkType: hard - "escodegen@npm:^2.0.0": version: 2.1.0 resolution: "escodegen@npm:2.1.0" @@ -9667,6 +9768,15 @@ __metadata: languageName: node linkType: hard +"filelist@npm:^1.0.4": + version: 1.0.6 + resolution: "filelist@npm:1.0.6" + dependencies: + minimatch: "npm:^5.0.1" + checksum: 10/84a0be69efe6724c105f18c34e8a772370d9c45e53a1ba8ced7eecf4addd2c5a357347d94bfd8bfa9cbc36b09392cad70d82206305263e26bba184eea4ca8042 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -10560,6 +10670,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^2.0.0": + version: 2.2.1 + resolution: "is-docker@npm:2.2.1" + bin: + is-docker: cli.js + checksum: 10/3fef7ddbf0be25958e8991ad941901bf5922ab2753c46980b60b05c1bf9c9c2402d35e6dc32e4380b980ef5e1970a5d9d5e5aa2e02d77727c3b6b5e918474c56 + languageName: node + linkType: hard + "is-docker@npm:^3.0.0": version: 3.0.0 resolution: "is-docker@npm:3.0.0" @@ -10694,6 +10813,15 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^2.2.0": + version: 2.2.0 + resolution: "is-wsl@npm:2.2.0" + dependencies: + is-docker: "npm:^2.0.0" + checksum: 10/20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8 + languageName: node + linkType: hard + "is-wsl@npm:^3.1.0": version: 3.1.0 resolution: "is-wsl@npm:3.1.0" @@ -10821,6 +10949,19 @@ __metadata: languageName: node linkType: hard +"jake@npm:^10.8.5": + version: 10.9.4 + resolution: "jake@npm:10.9.4" + dependencies: + async: "npm:^3.2.6" + filelist: "npm:^1.0.4" + picocolors: "npm:^1.1.1" + bin: + jake: bin/cli.js + checksum: 10/97e48f73f5e315a3b6e1a48b4bcc0cdf2c2cf82100ec9e76a032fd5d614dcd32c4315572cfcb66e9f9bdecca3900aaa61fe72b781a74b06aefd3ec4c1c917f0b + languageName: node + linkType: hard + "jest-changed-files@npm:^29.7.0": version: 29.7.0 resolution: "jest-changed-files@npm:29.7.0" @@ -11600,6 +11741,13 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.1.3": + version: 3.1.3 + resolution: "lilconfig@npm:3.1.3" + checksum: 10/b932ce1af94985f0efbe8896e57b1f814a48c8dbd7fc0ef8469785c6303ed29d0090af3ccad7e36b626bfca3a4dc56cc262697e9a8dd867623cf09a39d54e4c3 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -11993,6 +12141,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.5": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10/19e87a931aff60ee7b9d80f39f817b8bfc54f61f8356ee3549fbf636dbccacacfec8d803eac73293955c4527cd085247dfc064bce4a5e349f8f3b85e2bf5da0f + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -12002,6 +12159,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^5.0.1": + version: 5.1.9 + resolution: "minimatch@npm:5.1.9" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/23b4feb64dcb77ba93b70a72be551eb2e2677ac02178cf1ed3d38836cc4cd84802d90b77f60ef87f2bac64d270d2d8eba242e428f0554ea4e36bfdb7e9d25d0c + languageName: node + linkType: hard + "minimatch@npm:^7.4.6": version: 7.4.6 resolution: "minimatch@npm:7.4.6" @@ -12753,10 +12919,10 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 +"picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10/f6ef80a3590827ce20378ae110ac78209cc4f74d39236370f1780f957b7ee41c12acde0e4651b90f39983506fd2f5e449994716f516db2e9752924aff8de93ce languageName: node linkType: hard @@ -13910,7 +14076,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -14030,7 +14196,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8.0.0": +"supports-color@npm:^8, supports-color@npm:^8.0.0": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -14149,13 +14315,13 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" +"tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" dependencies: fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2 + picomatch: "npm:^4.0.4" + checksum: 10/5c2c41b572ada38449e7c86a5fe034f204a1dbba577225a761a14f29f48dc3f2fc0d81a6c56fcc67c5a742cc3aa9fb5e2ca18dbf22b610b0bc0e549b34d5a0f8 languageName: node linkType: hard @@ -14855,6 +15021,15 @@ __metadata: languageName: node linkType: hard +"widest-line@npm:^3.1.0": + version: 3.1.0 + resolution: "widest-line@npm:3.1.0" + dependencies: + string-width: "npm:^4.0.0" + checksum: 10/03db6c9d0af9329c37d74378ff1d91972b12553c7d72a6f4e8525fe61563fa7adb0b9d6e8d546b7e059688712ea874edd5ded475999abdeedf708de9849310e0 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5"