diff --git a/knip.config.ts b/knip.config.ts index caa51cc823..76da129592 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -165,6 +165,12 @@ const config: KnipConfig = { 'packages/user-operation-controller': { ignoreDependencies: ['immer'], }, + 'packages/wallet-cli': { + // `tsx` is the dev-mode loader: it's referenced only as a `node --import` + // argument string (in `daemon-spawn`'s source-entry path and `bin/dev`), + // never as a traceable import, so knip can't see it. + ignoreDependencies: ['tsx'], + }, 'packages/wallet-framework-docs': { // Source lives under `site/` instead of `src/`; tell knip to scan it // so the type imports of `@docusaurus/*` / `prism-react-renderer` in diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 9058dc2005..5e517f8d74 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add the `mm daemon` command suite (`start`, `stop`, `status`, `purge`, and `call`) for running the wallet daemon and dispatching messenger actions over its socket ([#9255](https://github.com/MetaMask/core/pull/9255)) - Add a wallet factory and daemon entry point that construct a `@metamask/wallet` `Wallet` backed by the SQLite key-value store, hydrate it from persisted state, run controller initialization (aborting startup if any step fails), import the secret recovery phrase on first run, and expose a `dispose` teardown handle ([#9226](https://github.com/MetaMask/core/pull/9226)) - Add a daemon transport layer: a JSON-RPC client and server over a Unix socket, plus daemon spawn/stop lifecycle helpers ([#9108](https://github.com/MetaMask/core/pull/9108)) - Add SQLite-backed persistence for wallet controller state ([#9067](https://github.com/MetaMask/core/pull/9067)) diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md index ddb25c377d..29ac82275b 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -10,6 +10,33 @@ or `npm install @metamask/wallet-cli` +## Usage + +The CLI drives a long-lived background **daemon** that holds an unlocked `@metamask/wallet` in memory and exposes its messenger over a per-user Unix socket. All commands live under the `mm daemon` topic; run `mm --help` (or `mm daemon --help`) for the full reference. + +Start the daemon (flags may also be supplied as the `INFURA_PROJECT_ID`, `MM_WALLET_PASSWORD`, and `MM_WALLET_SRP` environment variables — preferred for secrets): + +```sh +mm daemon start --infura-project-id --password --srp "" +``` + +Call any messenger action on the running wallet (positional JSON array for arguments, optional `--timeout`): + +```sh +mm daemon call AccountsController:listAccounts +mm daemon call KeyringController:getState --timeout 10000 +``` + +Inspect or tear it down: + +```sh +mm daemon status # PID + uptime, or why the socket is unreachable +mm daemon stop # graceful shutdown (falls back to SIGTERM/SIGKILL) +mm daemon purge # stop, then delete all daemon state files (--force to skip the prompt) +``` + +State (socket, PID file, log, and the SQLite database) lives in the per-user oclif data directory; override it with `MM_DATA_DIR`. + ## Troubleshooting ### Rebuilding `better-sqlite3` diff --git a/packages/wallet-cli/bin/dev.cmd b/packages/wallet-cli/bin/dev.cmd new file mode 100644 index 0000000000..57f056d96f --- /dev/null +++ b/packages/wallet-cli/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +node --import tsx --no-warnings=ExperimentalWarning "%~dp0\dev.mjs" %* diff --git a/packages/wallet-cli/bin/dev.mjs b/packages/wallet-cli/bin/dev.mjs new file mode 100755 index 0000000000..857ef9d96b --- /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/jest.config.js b/packages/wallet-cli/jest.config.js index ca08413339..bc5f44e07f 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -14,6 +14,16 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // The e2e test constructs a real KeyringController, which needs the Web + // Crypto API; this environment polyfills `crypto` when the test realm (Node + // < 21 under --experimental-vm-modules) lacks it. + testEnvironment: '/jest.environment.js', + + // The test harness in `src/test/` is exercised by the command tests but + // not all of its error/edge branches are worth driving directly — it's + // production code's test infrastructure, not production code itself. + coveragePathIgnorePatterns: ['.*/src/test/.*'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/wallet-cli/jest.environment.js b/packages/wallet-cli/jest.environment.js new file mode 100644 index 0000000000..78355ae77d --- /dev/null +++ b/packages/wallet-cli/jest.environment.js @@ -0,0 +1,24 @@ +const { TestEnvironment } = require('jest-environment-node'); + +/** + * The `wallet-factory` e2e test constructs a real `KeyringController`, whose + * default `@metamask/browser-passworder` encryptor uses the Web Crypto API — + * both `crypto` (for `getRandomValues`/`subtle`) and the `CryptoKey` constructor + * (for an `instanceof` check). Under `--experimental-vm-modules` the test realm + * has neither global on Node < 21, so polyfill them from `node:crypto` when + * absent — the same two globals `@metamask/wallet`'s own `Wallet.test.ts` sets. + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + const { webcrypto } = require('crypto'); + if (typeof this.global.crypto === 'undefined') { + this.global.crypto = webcrypto; + } + if (typeof this.global.CryptoKey === 'undefined') { + this.global.CryptoKey = webcrypto.CryptoKey; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 749220d8f5..aaa4f06e1c 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -61,7 +61,9 @@ "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", "ts-jest": "^29.2.5", + "tsx": "^4.20.5", "typescript": "~5.3.3" }, "oclif": { diff --git a/packages/wallet-cli/src/commands/daemon/call.test.ts b/packages/wallet-cli/src/commands/daemon/call.test.ts new file mode 100644 index 0000000000..c39f933b1a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.test.ts @@ -0,0 +1,152 @@ +import { sendCommand } from '../../daemon/daemon-client'; +import { runCommand } from '../../test/run-command'; +import DaemonCall from './call'; + +jest.mock('../../daemon/daemon-client'); + +const mockSendCommand = jest.mocked(sendCommand); + +const ACTION = 'AccountsController:listAccounts'; + +describe('daemon call', () => { + beforeEach(() => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { accounts: [] }, + }); + }); + + it('dispatches the action with no params', async () => { + await runCommand(DaemonCall, [ACTION]); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'call', + params: [ACTION], + }), + ); + }); + + it('parses a JSON-array params argument and appends to the params list', async () => { + await runCommand(DaemonCall, [ACTION, '["arg1", 42]']); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'call', + params: [ACTION, 'arg1', 42], + }), + ); + }); + + it('errors when params is not valid JSON', async () => { + const { error } = await runCommand(DaemonCall, [ACTION, 'not json']); + + expect(error?.message).toContain('valid JSON'); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it('errors when params is JSON but not an array', async () => { + const { error } = await runCommand(DaemonCall, [ACTION, '{"foo":1}']); + + expect(error?.message).toContain('JSON array'); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it('passes the timeout flag through to sendCommand', async () => { + await runCommand(DaemonCall, [ACTION, '--timeout', '5000']); + + expect(mockSendCommand).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 5000 }), + ); + }); + + it('returns a friendly hint when the daemon is not running (ENOENT)', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('no such file'), { code: 'ENOENT' }), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('returns a friendly hint when the daemon refuses the connection', async () => { + mockSendCommand.mockRejectedValue( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Daemon is not running'); + }); + + it('surfaces other socket errors with the raw message', async () => { + mockSendCommand.mockRejectedValue(new Error('Socket read timed out')); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Socket read timed out'); + }); + + it('handles non-Error throws from sendCommand', async () => { + mockSendCommand.mockImplementation(async () => + Promise.reject('string error' as unknown as Error), + ); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('string error'); + }); + + it('errors when the daemon returns a JSON-RPC failure response', async () => { + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { code: -32601, message: 'Method not found' }, + }); + + const { error } = await runCommand(DaemonCall, [ACTION]); + + expect(error?.message).toContain('Method not found'); + expect(error?.message).toContain('-32601'); + }); + + it('writes pretty JSON to a TTY stdout', async () => { + const original = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + const { stdout } = await runCommand(DaemonCall, [ACTION]); + + expect(stdout).toContain('"accounts": []'); + + Object.defineProperty(process.stdout, 'isTTY', { + value: original, + configurable: true, + }); + }); + + it('writes compact JSON to a piped (non-TTY) stdout', async () => { + const original = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + const writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + await runCommand(DaemonCall, [ACTION]); + + expect(writeSpy).toHaveBeenCalledWith('{"accounts":[]}\n'); + + writeSpy.mockRestore(); + Object.defineProperty(process.stdout, 'isTTY', { + value: original, + configurable: true, + }); + }); +}); 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 0000000000..9961c094ec --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -0,0 +1,95 @@ +import type { Json } from '@metamask/utils'; +import { isJsonRpcFailure } from '@metamask/utils'; +import { Args, Command, Flags } from '@oclif/core'; + +import { sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { isErrorWithCode } from '../../daemon/utils'; + +export default class DaemonCall extends Command { + static override description = 'Call a messenger action on the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon call KeyringController:getState', + '<%= config.bin %> daemon call NetworkController:getState', + '<%= config.bin %> daemon call ApprovalController: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; + + // The daemon's `call` RPC expects `[action, ...args]`. `JSON.parse` returns + // `unknown`, but anything it produces is structurally `Json`, so we cast to + // `Json[]` once we've confirmed the parsed payload is an array. + const rpcParams: Json[] = [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.push(...(parsed as Json[])); + } + + const { socketPath } = getDaemonPaths(this.config.dataDir); + + let response; + try { + response = await sendCommand({ + socketPath, + method: 'call', + params: rpcParams, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + } catch (error) { + if ( + isErrorWithCode(error, 'ENOENT') || + isErrorWithCode(error, 'ECONNREFUSED') + ) { + this.error('Daemon is not running. Start it with `mm daemon start`.'); + } + this.error(error instanceof Error ? error.message : String(error)); + } + + 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.test.ts b/packages/wallet-cli/src/commands/daemon/purge.test.ts new file mode 100644 index 0000000000..78c2d39aed --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.test.ts @@ -0,0 +1,115 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon } from '../../daemon/daemon-client'; +import { confirmPurge } from '../../daemon/prompts'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { runCommand } from '../../test/run-command'; +import DaemonPurge from './purge'; + +jest.mock('node:fs/promises'); +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/stop-daemon'); +jest.mock('../../daemon/prompts'); + +const inquirerConfirm = jest.mocked(confirmPurge); +const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockStopDaemon = jest.mocked(stopDaemon); + +describe('daemon purge', () => { + beforeEach(() => { + mockRm.mockResolvedValue(undefined); + inquirerConfirm.mockResolvedValue(true); + }); + + it('aborts without prompting nor deleting when the user declines', async () => { + inquirerConfirm.mockResolvedValue(false); + + const { stdout, error } = await runCommand(DaemonPurge); + + expect(stdout).toContain('Aborted.'); + expect(mockStopDaemon).not.toHaveBeenCalled(); + expect(mockRm).not.toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('--force skips the confirmation prompt', async () => { + mockStopDaemon.mockResolvedValue(true); + + await runCommand(DaemonPurge, ['--force']); + + expect(inquirerConfirm).not.toHaveBeenCalled(); + expect(mockStopDaemon).toHaveBeenCalled(); + }); + + it('threads its log callback into stopDaemon so daemon-side messages reach the user', async () => { + mockStopDaemon.mockImplementation(async (_socket, _pid, log) => { + log?.('Stopping daemon...'); + return true; + }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('Stopping daemon...'); + }); + + it('refuses to delete state when the daemon is still responsive', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + + const { error } = await runCommand(DaemonPurge, ['--force']); + + expect(error?.message).toContain('still responsive'); + expect(mockRm).not.toHaveBeenCalled(); + }); + + it('proceeds to delete the whitelist when stopDaemon returns false but the daemon is unresponsive', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'refused', + error: new Error('refused'), + }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('Could not confirm clean shutdown'); + expect(stdout).toContain('All daemon state deleted.'); + }); + + it('proceeds when stopDaemon returns false and the daemon is absent', async () => { + mockStopDaemon.mockResolvedValue(false); + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + + const { stdout } = await runCommand(DaemonPurge, ['--force']); + + expect(stdout).toContain('All daemon state deleted.'); + }); + + it('deletes only the whitelisted daemon files (not the entire dataDir)', async () => { + mockStopDaemon.mockResolvedValue(true); + + await runCommand(DaemonPurge, ['--force']); + + const removed = mockRm.mock.calls.map(([path]) => path); + expect(removed).not.toContain('/tmp/mm-cli-test-data'); + expect(removed.some((path) => String(path).endsWith('daemon.pid'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('daemon.sock'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('daemon.log'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db-wal'))).toBe( + true, + ); + expect(removed.some((path) => String(path).endsWith('wallet.db-shm'))).toBe( + true, + ); + }); +}); 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 0000000000..d3f56d593a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -0,0 +1,76 @@ +import { Command, Flags } from '@oclif/core'; +import { rm } from 'node:fs/promises'; + +import { pingDaemon } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { confirmPurge } from '../../daemon/prompts'; +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 confirmed = await confirmPurge(); + if (!confirmed) { + this.log('Aborted.'); + return; + } + } + + const paths = getDaemonPaths(this.config.dataDir); + + const stopped = await stopDaemon( + paths.socketPath, + paths.pidPath, + (message) => this.log(message), + ); + + if (!stopped) { + // Purge is the escape hatch for a daemon that wouldn't shut down cleanly, + // so proceed once we've confirmed it isn't responsive — deleting state + // out from under a live daemon would risk corrupting it. + const ping = await pingDaemon(paths.socketPath); + if (ping.status === 'responsive') { + this.error( + 'Refusing to delete state while the daemon is still responsive.', + ); + } + this.log( + 'Could not confirm clean shutdown; proceeding to delete state anyway.', + ); + } + + // Whitelist only the daemon-owned files rather than rm'ing the entire + // oclif dataDir, which may hold unrelated state (caches, oclif lock + // files, future config). `force: true` makes ENOENT a no-op for any + // file already removed by stopDaemon. + await Promise.all( + [ + paths.pidPath, + paths.socketPath, + paths.logPath, + paths.dbPath, + `${paths.dbPath}-wal`, + `${paths.dbPath}-shm`, + ].map(async (path) => rm(path, { force: true })), + ); + + this.log('All daemon state deleted.'); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/start.test.ts b/packages/wallet-cli/src/commands/daemon/start.test.ts new file mode 100644 index 0000000000..68e044010e --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.test.ts @@ -0,0 +1,41 @@ +import { ensureDaemon } from '../../daemon/daemon-spawn'; +import { runCommand } from '../../test/run-command'; +import DaemonStart from './start'; + +jest.mock('../../daemon/daemon-spawn'); + +const mockEnsureDaemon = jest.mocked(ensureDaemon); + +const FLAGS = [ + '--infura-project-id', + 'key', + '--password', + 'pw', + '--srp', + 'phrase', +]; + +describe('daemon start', () => { + it('reports the socket path on a fresh start', async () => { + mockEnsureDaemon.mockResolvedValue({ + state: 'started', + socketPath: '/tmp/daemon.sock', + }); + + const { stdout } = await runCommand(DaemonStart, FLAGS); + + expect(stdout).toContain('Daemon running. Socket: /tmp/daemon.sock'); + }); + + it('warns that flags were not applied when a daemon is already running', async () => { + mockEnsureDaemon.mockResolvedValue({ + state: 'already-running', + socketPath: '/tmp/daemon.sock', + }); + + const { stdout } = await runCommand(DaemonStart, FLAGS); + + expect(stdout).toContain('Daemon already running'); + expect(stdout).toContain('not applied'); + }); +}); 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 0000000000..fb14e29f4d --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -0,0 +1,56 @@ +import { Command, Flags } from '@oclif/core'; + +import { ensureDaemon } from '../../daemon/daemon-spawn'; + +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', + ]; + + 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; + + const { state, socketPath } = await ensureDaemon({ + dataDir: this.config.dataDir, + infuraProjectId, + password, + srp, + packageRoot: this.config.root, + }); + + if (state === 'already-running') { + this.log( + `Daemon already running. Socket: ${socketPath}. ` + + `The provided flags were not applied; run \`mm daemon stop\` and start again to change them.`, + ); + return; + } + + this.log(`Daemon running. Socket: ${socketPath}`); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/status.test.ts b/packages/wallet-cli/src/commands/daemon/status.test.ts new file mode 100644 index 0000000000..405056eb8b --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.test.ts @@ -0,0 +1,117 @@ +import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; +import { readPidFile } from '../../daemon/utils'; +import { runCommand } from '../../test/run-command'; +import DaemonStatus from './status'; + +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/utils'); + +const mockPingDaemon = jest.mocked(pingDaemon); +const mockSendCommand = jest.mocked(sendCommand); +const mockReadPidFile = jest.mocked(readPidFile); + +describe('daemon status', () => { + beforeEach(() => { + mockReadPidFile.mockResolvedValue(12345); + }); + + it('reports "not running" when the socket is absent', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('Daemon is not running.'); + }); + + it('reports the unreachable reason and recorded PID', async () => { + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'refused', + error: new Error('ECONNREFUSED'), + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('is unresponsive'); + expect(stdout).toContain('recorded PID: 12345'); + expect(stdout).toContain('[refused]'); + expect(stdout).toContain('ECONNREFUSED'); + }); + + it('omits the PID suffix when no PID file is present', async () => { + mockReadPidFile.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue({ + status: 'unreachable', + reason: 'timeout', + error: new Error('timeout'), + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('is unresponsive'); + expect(stdout).not.toContain('recorded PID'); + }); + + it('reports a status-request failure distinctly from an absent or unreachable daemon', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockRejectedValue(new Error('timed out')); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('responsive but status request failed'); + expect(stdout).toContain('timed out'); + }); + + it('reports a JSON-RPC error response from getStatus', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + error: { code: -32000, message: 'boom' }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('returned an error: boom'); + }); + + it('reports PID and uptime on success', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { pid: 12345, uptime: 42 }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('PID: 12345, Uptime: 42s'); + }); + + it('warns when the local PID file disagrees with the running daemon', async () => { + mockReadPidFile.mockResolvedValue(99999); + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { pid: 12345, uptime: 42 }, + }); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain( + 'Warning: PID file records 99999 but the running daemon reports 12345', + ); + }); + + it('handles non-Error throws from sendCommand', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockSendCommand.mockImplementation(async () => + Promise.reject('string error' as unknown as Error), + ); + + const { stdout } = await runCommand(DaemonStatus); + + expect(stdout).toContain('status request failed: string error'); + }); +}); 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 0000000000..69611aad50 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -0,0 +1,66 @@ +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 { 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 ping = await pingDaemon(socketPath); + + if (ping.status === 'absent') { + this.log('Daemon is not running.'); + return; + } + + if (ping.status === 'unreachable') { + const pidPart = pid === undefined ? '' : ` (recorded PID: ${pid})`; + this.log( + `Daemon socket exists at ${socketPath} but is unresponsive${pidPart} ` + + `[${ping.reason}]: ${ping.error.message}`, + ); + 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; + if (pid !== undefined && pid !== status.pid) { + this.log( + `Warning: PID file records ${pid} but the running daemon reports ${status.pid}. ` + + `Local state may be stale; consider \`mm daemon purge\`.`, + ); + } + this.log( + `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, + ); + } +} diff --git a/packages/wallet-cli/src/commands/daemon/stop.test.ts b/packages/wallet-cli/src/commands/daemon/stop.test.ts new file mode 100644 index 0000000000..b30d7152fa --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.test.ts @@ -0,0 +1,71 @@ +import { pingDaemon } from '../../daemon/daemon-client'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { readPidFile } from '../../daemon/utils'; +import { runCommand } from '../../test/run-command'; +import DaemonStop from './stop'; + +jest.mock('../../daemon/daemon-client'); +jest.mock('../../daemon/stop-daemon'); +jest.mock('../../daemon/utils'); + +const mockPingDaemon = jest.mocked(pingDaemon); +const mockStopDaemon = jest.mocked(stopDaemon); +const mockReadPidFile = jest.mocked(readPidFile); + +describe('daemon stop', () => { + it('reports "Daemon is not running" when no socket and no PID file exist', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + mockReadPidFile.mockResolvedValue(undefined); + + const { stdout, error } = await runCommand(DaemonStop); + + expect(stdout).toContain('Daemon is not running.'); + expect(mockStopDaemon).not.toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('invokes stopDaemon when a PID file exists even if the socket is absent', async () => { + mockPingDaemon.mockResolvedValue({ status: 'absent' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(true); + + const { error } = await runCommand(DaemonStop); + + expect(mockStopDaemon).toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('invokes stopDaemon when the socket is responsive', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(true); + + const { error } = await runCommand(DaemonStop); + + expect(mockStopDaemon).toHaveBeenCalled(); + expect(error).toBeUndefined(); + }); + + it('threads its log callback into stopDaemon so daemon-side messages reach the user', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockImplementation(async (_socket, _pid, log) => { + log?.('Stopping daemon...'); + return true; + }); + + const { stdout } = await runCommand(DaemonStop); + + expect(stdout).toContain('Stopping daemon...'); + }); + + it('errors when stopDaemon returns false', async () => { + mockPingDaemon.mockResolvedValue({ status: 'responsive' }); + mockReadPidFile.mockResolvedValue(12345); + mockStopDaemon.mockResolvedValue(false); + + const { error } = await runCommand(DaemonStop); + + expect(error?.message).toContain('did not stop within timeout'); + }); +}); 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 0000000000..238db80374 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -0,0 +1,31 @@ +import { Command } from '@oclif/core'; + +import { pingDaemon } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { stopDaemon } from '../../daemon/stop-daemon'; +import { readPidFile } from '../../daemon/utils'; + +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 ping = await pingDaemon(socketPath); + const pid = await readPidFile(pidPath); + if (ping.status === 'absent' && pid === undefined) { + this.log('Daemon is not running.'); + return; + } + + 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/wallet-factory.e2e.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts new file mode 100644 index 0000000000..a8a53498f3 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts @@ -0,0 +1,34 @@ +import { createWallet } from './wallet-factory'; + +// Unlike the unit test alongside it, this does NOT mock `@metamask/wallet`, so +// it covers what the mocked test can't: that `buildInstanceOptions` produces a +// working real `Wallet`. Safe to run offline — neither `Wallet` construction +// nor `wallet.init()` reaches the network: RemoteFeatureFlagController only +// fetches in `updateRemoteFeatureFlags`, and NetworkController's `init()` is +// synchronous and does not call `lookupNetwork`. + +const TEST_SRP = 'test test test test test test test test test test test ball'; +const TEST_PASSWORD = 'testpass'; + +describe('createWallet (real Wallet, in-memory)', () => { + it('constructs an unlocked wallet on first run and dispatches messenger actions', async () => { + const { wallet, dispose } = await createWallet({ + databasePath: ':memory:', + password: TEST_PASSWORD, + srp: TEST_SRP, + infuraProjectId: 'test-infura-id', + log: () => undefined, + }); + + try { + expect(wallet.state.KeyringController?.isUnlocked).toBe(true); + + // `getState` resolves synchronously; awaiting a non-thenable trips + // `@typescript-eslint/await-thenable`. + const { keyrings } = wallet.messenger.call('KeyringController:getState'); + expect(keyrings[0]?.accounts[0]).toMatch(/^0x[0-9a-fA-F]{40}$/u); + } finally { + await dispose(); + } + }, 30_000); +}); diff --git a/packages/wallet-cli/src/test/run-command.ts b/packages/wallet-cli/src/test/run-command.ts new file mode 100644 index 0000000000..de100c3ca2 --- /dev/null +++ b/packages/wallet-cli/src/test/run-command.ts @@ -0,0 +1,86 @@ +import { Command } from '@oclif/core'; +import type { Config } from '@oclif/core'; +import { CLIError } from '@oclif/core/errors'; + +type CommandCtor = new ( + argv: string[], + config: Config, +) => Command & { + _run: () => Promise; +}; + +const TEST_DATA_DIR = '/tmp/mm-cli-test-data'; +const TEST_PACKAGE_ROOT = '/tmp/mm-cli-test-root'; + +/** + * Invoke an oclif command class with the given argv and return the captured + * stdout/stderr/error so tests can assert on them. + * + * Bypasses `Command.run`'s static plugin-loading path (which requires a real + * `Config.load`) by constructing the command instance directly with a + * hand-rolled `Config` and invoking the protected `_run`. Spies on the + * Command prototype so `this.log` and `this.error` go to local buffers + * instead of stdout/stderr. + * + * @param CommandClass - The command class (a subclass of `@oclif/core` Command). + * @param argv - Command-line tokens (flags + positional args). + * @returns Captured stdout, stderr, and any `this.error()` payload. + */ +export async function runCommand( + CommandClass: CommandCtor, + argv: string[] = [], +): Promise<{ + stdout: string; + stderr: string; + error: CLIError | undefined; +}> { + let stdout = ''; + let stderr = ''; + let error: CLIError | undefined; + + const fakeConfig = { + dataDir: TEST_DATA_DIR, + root: TEST_PACKAGE_ROOT, + bin: 'mm', + name: '@metamask/wallet-cli', + version: '0.0.0-test', + pjson: { name: '@metamask/wallet-cli', version: '0.0.0-test' }, + findCommand: () => undefined, + runHook: async () => ({ successes: [], failures: [] }), + scopedEnvVar: () => undefined, + scopedEnvVarKey: () => '', + scopedEnvVarKeys: () => [], + scopedEnvVarTrue: () => false, + plugins: new Map(), + flexibleTaxonomy: false, + } as unknown as Config; + + const logSpy = jest + .spyOn(Command.prototype, 'log') + .mockImplementation((message: unknown = '') => { + stdout += `${String(message)}\n`; + }); + const errorSpy = jest + .spyOn(Command.prototype, 'error') + .mockImplementation((input: string | Error) => { + const message = typeof input === 'string' ? input : input.message; + throw new CLIError(message); + }); + + try { + const instance = new CommandClass(argv, fakeConfig); + await instance._run(); + } catch (caught: unknown) { + if (caught instanceof CLIError) { + error = caught; + stderr += `${caught.message}\n`; + } else { + throw caught; + } + } finally { + logSpy.mockRestore(); + errorSpy.mockRestore(); + } + + return { stdout, stderr, error }; +} diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json index c75b54d488..7a5498120f 100644 --- a/packages/wallet-cli/tsconfig.json +++ b/packages/wallet-cli/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../../tsconfig.packages.json", "compilerOptions": { - "baseUrl": "./" + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" }, "references": [ { "path": "../base-controller/tsconfig.json" }, diff --git a/yarn.lock b/yarn.lock index 5b839a1f04..cb3c3ad059 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8897,7 +8897,9 @@ __metadata: deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" bin: mm: ./bin/run.mjs