From aff945bd4399d80d2030ccde850153299501fe17 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 24 Jun 2026 18:31:02 +0200 Subject: [PATCH 01/10] feat(wallet-cli): add daemon commands, dev-mode launchers, and e2e smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `mm daemon` command suite — `start`, `stop`, `status`, `purge`, and `call` — completing the CLI's user-facing surface, plus the oclif test harness (`src/test/run-command.ts`) and the dev-mode launchers (`bin/dev.mjs`, `bin/dev.cmd`) deferred from the scaffold slice. The commands are Erik's verbatim; their imported daemon interfaces all match the modules already on `main` from the persistence/transport/factory slices. Also add `tsx` as the dev-mode `--import` loader (with a knip `ignoreDependencies` entry, since it's referenced only as an argument string), exclude the test harness from coverage, and add a real-construction e2e smoke test that feeds `buildInstanceOptions` into a real `Wallet` against `:memory:` — closing the gap that the mocked unit test cannot reach. Documents the `mm daemon` usage in the README and adds an ARCHITECTURE.md describing the daemon → factory → persistence → transport layering. Co-Authored-By: Claude Opus 4.8 (1M context) --- knip.config.ts | 6 + packages/wallet-cli/CHANGELOG.md | 1 + packages/wallet-cli/README.md | 27 ++++ packages/wallet-cli/bin/dev.cmd | 3 + packages/wallet-cli/bin/dev.mjs | 3 + packages/wallet-cli/jest.config.js | 5 + packages/wallet-cli/package.json | 1 + .../src/commands/daemon/call.test.ts | 153 ++++++++++++++++++ .../wallet-cli/src/commands/daemon/call.ts | 95 +++++++++++ .../src/commands/daemon/purge.test.ts | 118 ++++++++++++++ .../wallet-cli/src/commands/daemon/purge.ts | 81 ++++++++++ .../src/commands/daemon/start.test.ts | 41 +++++ .../wallet-cli/src/commands/daemon/start.ts | 56 +++++++ .../src/commands/daemon/status.test.ts | 118 ++++++++++++++ .../wallet-cli/src/commands/daemon/status.ts | 66 ++++++++ .../src/commands/daemon/stop.test.ts | 71 ++++++++ .../wallet-cli/src/commands/daemon/stop.ts | 33 ++++ .../src/daemon/wallet-factory.e2e.test.ts | 37 +++++ packages/wallet-cli/src/test/run-command.ts | 86 ++++++++++ yarn.lock | 1 + 20 files changed, 1002 insertions(+) create mode 100644 packages/wallet-cli/bin/dev.cmd create mode 100755 packages/wallet-cli/bin/dev.mjs create mode 100644 packages/wallet-cli/src/commands/daemon/call.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/call.ts create mode 100644 packages/wallet-cli/src/commands/daemon/purge.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/purge.ts create mode 100644 packages/wallet-cli/src/commands/daemon/start.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/start.ts create mode 100644 packages/wallet-cli/src/commands/daemon/status.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/status.ts create mode 100644 packages/wallet-cli/src/commands/daemon/stop.test.ts create mode 100644 packages/wallet-cli/src/commands/daemon/stop.ts create mode 100644 packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts create mode 100644 packages/wallet-cli/src/test/run-command.ts 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..5cd22089ff 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 ([#0000](https://github.com/MetaMask/core/pull/0000)) - 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..cc07be2a00 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_DAEMON_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..ee0f58bfe9 --- /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 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..aacc39c51a 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -14,6 +14,11 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // 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/package.json b/packages/wallet-cli/package.json index 749220d8f5..2078a11d68 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -62,6 +62,7 @@ "deepmerge": "^4.2.2", "jest": "^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..c43ec9c91b --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.test.ts @@ -0,0 +1,153 @@ +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 () => + // Simulate a non-Error throw (the call site does not narrow to Error). + 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..37a902439b --- /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 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; + + // 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..7189981e0c --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.test.ts @@ -0,0 +1,118 @@ +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); + // The whitelist is built from getDaemonPaths(dataDir).{pidPath,socketPath, + // logPath,dbPath} plus the SQLite WAL/SHM sidecars. None of them is the + // dataDir itself. + 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..cd25660bf8 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -0,0 +1,81 @@ +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) { + // `stopDaemon` returns false when it couldn't be sure the daemon + // exited — typically because the socket exists but the daemon never + // responded to signals, or because the PID file is stale and the + // socket is orphan. Purge is the user's escape hatch for exactly + // these states, so as long as the daemon is not currently + // responsive, we proceed with the deletion the user already + // confirmed. If the daemon IS responsive, we still refuse — that + // would risk corrupting live state. + 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..47f18a9cfd --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.test.ts @@ -0,0 +1,118 @@ +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 () => + // Simulate a non-Error throw (the call site does not narrow to Error). + 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..62a85588fa --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -0,0 +1,33 @@ +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); + + // Distinguish "no daemon was running" from "successful stop" so the user + // gets feedback either way. + 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..c77fca340d --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts @@ -0,0 +1,37 @@ +import { createWallet } from './wallet-factory'; + +// This suite deliberately does NOT mock `@metamask/wallet` (unlike the unit +// test alongside it). It feeds the real `buildInstanceOptions` into a real +// `Wallet` against an in-memory database, closing the gap that the mocked unit +// test cannot reach: that the wired `instanceOptions` actually construct a +// working wallet. Constructing a `Wallet` never triggers the +// RemoteFeatureFlagController's network fetch (that only happens on an explicit +// `updateRemoteFeatureFlags` call), so this stays offline and CI-safe. + +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, + log: () => undefined, + }); + + try { + // First-run SRP import unlocks the keyring — proof the real wallet built + // from `buildInstanceOptions` is functional, not just constructed. + expect(wallet.state.KeyringController?.isUnlocked).toBe(true); + + // Dispatch through the messenger exactly as the daemon's `call` handler + // does, to prove the wired controllers respond. `listAccounts` resolves + // synchronously, so it is not awaited. + const accounts = wallet.messenger.call('AccountsController:listAccounts'); + expect(accounts).toHaveLength(1); + } 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/yarn.lock b/yarn.lock index 5b839a1f04..a7a96b45ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8898,6 +8898,7 @@ __metadata: immer: "npm:^9.0.6" jest: "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 From 7620a322a6644e0331770aa6b6e896d5603629fa Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 24 Jun 2026 18:32:41 +0200 Subject: [PATCH 02/10] docs(wallet-cli): link PR #9255 in changelog Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index 5cd22089ff..5e517f8d74 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -9,7 +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 ([#0000](https://github.com/MetaMask/core/pull/0000)) +- 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)) From a1b475cdf667c046a065a28f5fe17250e463f471 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 24 Jun 2026 18:35:46 +0200 Subject: [PATCH 03/10] test(wallet-cli): trim e2e comments to non-obvious why only Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/daemon/wallet-factory.e2e.test.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts index c77fca340d..f05e7d1bd3 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts @@ -1,12 +1,10 @@ import { createWallet } from './wallet-factory'; -// This suite deliberately does NOT mock `@metamask/wallet` (unlike the unit -// test alongside it). It feeds the real `buildInstanceOptions` into a real -// `Wallet` against an in-memory database, closing the gap that the mocked unit -// test cannot reach: that the wired `instanceOptions` actually construct a -// working wallet. Constructing a `Wallet` never triggers the -// RemoteFeatureFlagController's network fetch (that only happens on an explicit -// `updateRemoteFeatureFlags` call), so this stays offline and CI-safe. +// 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 — `Wallet` construction never +// triggers RemoteFeatureFlagController's fetch (only `updateRemoteFeatureFlags` +// does). const TEST_SRP = 'test test test test test test test test test test test ball'; const TEST_PASSWORD = 'testpass'; @@ -21,13 +19,10 @@ describe('createWallet (real Wallet, in-memory)', () => { }); try { - // First-run SRP import unlocks the keyring — proof the real wallet built - // from `buildInstanceOptions` is functional, not just constructed. expect(wallet.state.KeyringController?.isUnlocked).toBe(true); - // Dispatch through the messenger exactly as the daemon's `call` handler - // does, to prove the wired controllers respond. `listAccounts` resolves - // synchronously, so it is not awaited. + // `listAccounts` resolves synchronously; awaiting a non-thenable trips + // `@typescript-eslint/await-thenable`. const accounts = wallet.messenger.call('AccountsController:listAccounts'); expect(accounts).toHaveLength(1); } finally { From 83d6be0ea65bf4fda4d38fc4e3ecee2533d5745b Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Wed, 24 Jun 2026 18:44:47 +0200 Subject: [PATCH 04/10] fix(wallet-cli): correct data-dir env var in README; harden e2e assertion README documented MM_DAEMON_DATA_DIR as the data-dir override, but that's only the internal start->daemon spawn-contract var (overwritten with config.dataDir and ignored by the other commands). The real user override is MM_DATA_DIR, the oclif scopedEnvVar('DATA_DIR'). Also assert the e2e's first account is a real SRP-derived EVM address, so it fails loudly on an empty/placeholder account. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/README.md | 2 +- packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md index cc07be2a00..29ac82275b 100644 --- a/packages/wallet-cli/README.md +++ b/packages/wallet-cli/README.md @@ -35,7 +35,7 @@ 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_DAEMON_DATA_DIR`. +State (socket, PID file, log, and the SQLite database) lives in the per-user oclif data directory; override it with `MM_DATA_DIR`. ## Troubleshooting diff --git a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts index f05e7d1bd3..88c4f52b60 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts @@ -25,6 +25,7 @@ describe('createWallet (real Wallet, in-memory)', () => { // `@typescript-eslint/await-thenable`. const accounts = wallet.messenger.call('AccountsController:listAccounts'); expect(accounts).toHaveLength(1); + expect(accounts[0]?.address).toMatch(/^0x[0-9a-fA-F]{40}$/u); } finally { await dispose(); } From 48b3b3e2880aa94d80dfb3bd6762190a71221825 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 25 Jun 2026 19:31:36 +0200 Subject: [PATCH 05/10] test(wallet-cli): supply required infuraProjectId in e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9001 (NetworkController) landed and wired on main, making `infuraProjectId` a required field of `CreateWalletConfig` and adding `wallet.init()` to the factory's startup path. Pass a dummy project ID so the e2e type-checks against the merged factory, and note in the comment that `init()` (NetworkController) is offline-safe too — it is synchronous and never calls `lookupNetwork`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts index 88c4f52b60..cdd7c13348 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts @@ -2,9 +2,10 @@ 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 — `Wallet` construction never -// triggers RemoteFeatureFlagController's fetch (only `updateRemoteFeatureFlags` -// does). +// 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'; @@ -15,6 +16,7 @@ describe('createWallet (real Wallet, in-memory)', () => { databasePath: ':memory:', password: TEST_PASSWORD, srp: TEST_SRP, + infuraProjectId: 'test-infura-id', log: () => undefined, }); From 12029ec824730094d9a637566e4eea0f7d1fe53f Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 25 Jun 2026 19:31:36 +0200 Subject: [PATCH 06/10] refactor(wallet-cli): drop comments deducible from the code Remove narration that restates what the code or test name already says (the stop-vs-not-running branch, the "simulate a non-Error throw" notes, the purge whitelist composition) and tighten the purge refuse-when- responsive comment to its non-obvious rationale. Keeps the genuine "why" comments (the call.ts Json cast safety, the purge whitelist rationale). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/src/commands/daemon/call.test.ts | 1 - packages/wallet-cli/src/commands/daemon/purge.test.ts | 3 --- packages/wallet-cli/src/commands/daemon/purge.ts | 11 +++-------- .../wallet-cli/src/commands/daemon/status.test.ts | 1 - packages/wallet-cli/src/commands/daemon/stop.ts | 2 -- 5 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/wallet-cli/src/commands/daemon/call.test.ts b/packages/wallet-cli/src/commands/daemon/call.test.ts index c43ec9c91b..c39f933b1a 100644 --- a/packages/wallet-cli/src/commands/daemon/call.test.ts +++ b/packages/wallet-cli/src/commands/daemon/call.test.ts @@ -91,7 +91,6 @@ describe('daemon call', () => { it('handles non-Error throws from sendCommand', async () => { mockSendCommand.mockImplementation(async () => - // Simulate a non-Error throw (the call site does not narrow to Error). Promise.reject('string error' as unknown as Error), ); diff --git a/packages/wallet-cli/src/commands/daemon/purge.test.ts b/packages/wallet-cli/src/commands/daemon/purge.test.ts index 7189981e0c..78c2d39aed 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.test.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.test.ts @@ -92,9 +92,6 @@ describe('daemon purge', () => { await runCommand(DaemonPurge, ['--force']); const removed = mockRm.mock.calls.map(([path]) => path); - // The whitelist is built from getDaemonPaths(dataDir).{pidPath,socketPath, - // logPath,dbPath} plus the SQLite WAL/SHM sidecars. None of them is the - // dataDir itself. expect(removed).not.toContain('/tmp/mm-cli-test-data'); expect(removed.some((path) => String(path).endsWith('daemon.pid'))).toBe( true, diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index cd25660bf8..d3f56d593a 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -42,14 +42,9 @@ export default class DaemonPurge extends Command { ); if (!stopped) { - // `stopDaemon` returns false when it couldn't be sure the daemon - // exited — typically because the socket exists but the daemon never - // responded to signals, or because the PID file is stale and the - // socket is orphan. Purge is the user's escape hatch for exactly - // these states, so as long as the daemon is not currently - // responsive, we proceed with the deletion the user already - // confirmed. If the daemon IS responsive, we still refuse — that - // would risk corrupting live state. + // 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( diff --git a/packages/wallet-cli/src/commands/daemon/status.test.ts b/packages/wallet-cli/src/commands/daemon/status.test.ts index 47f18a9cfd..405056eb8b 100644 --- a/packages/wallet-cli/src/commands/daemon/status.test.ts +++ b/packages/wallet-cli/src/commands/daemon/status.test.ts @@ -107,7 +107,6 @@ describe('daemon status', () => { it('handles non-Error throws from sendCommand', async () => { mockPingDaemon.mockResolvedValue({ status: 'responsive' }); mockSendCommand.mockImplementation(async () => - // Simulate a non-Error throw (the call site does not narrow to Error). Promise.reject('string error' as unknown as Error), ); diff --git a/packages/wallet-cli/src/commands/daemon/stop.ts b/packages/wallet-cli/src/commands/daemon/stop.ts index 62a85588fa..238db80374 100644 --- a/packages/wallet-cli/src/commands/daemon/stop.ts +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -13,8 +13,6 @@ export default class DaemonStop extends Command { public async run(): Promise { const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); - // Distinguish "no daemon was running" from "successful stop" so the user - // gets feedback either way. const ping = await pingDaemon(socketPath); const pid = await readPidFile(pidPath); if (ping.status === 'absent' && pid === undefined) { From c29e73e14f40e3ff6a083eac76f2ab214b9d8391 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 25 Jun 2026 20:40:22 +0200 Subject: [PATCH 07/10] test(wallet-cli): polyfill Web Crypto in the jest environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e constructs a real KeyringController, whose default browser-passworder encryptor uses the Web Crypto API — `crypto` (`getRandomValues`/`subtle`) and the `CryptoKey` constructor (an `instanceof` check in `exportKey`). Under `--experimental-vm-modules` the test realm has neither global on Node < 21 (notably the CI `test-18` job), so the e2e failed first with "reading 'getRandomValues'" and then "CryptoKey is not defined". Add a custom jest environment that polyfills both from `node:crypto` when absent — the same two globals `@metamask/wallet`'s own `Wallet.test.ts` sets for this flow. Verified on Node 18.20.8 by driving the real browser-passworder `generateSalt -> keyFromPassword -> exportKey` sequence: it throws under the bare `node` environment and passes under this one. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/jest.config.js | 5 +++++ packages/wallet-cli/jest.environment.js | 24 ++++++++++++++++++++++++ packages/wallet-cli/package.json | 1 + yarn.lock | 1 + 4 files changed, 31 insertions(+) create mode 100644 packages/wallet-cli/jest.environment.js diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index aacc39c51a..bc5f44e07f 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -14,6 +14,11 @@ 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. 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 2078a11d68..aaa4f06e1c 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -61,6 +61,7 @@ "@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" diff --git a/yarn.lock b/yarn.lock index a7a96b45ac..cb3c3ad059 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8897,6 +8897,7 @@ __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" From 601bf11449a1e5b7fc465bfcd73c1f1c5a19c615 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 25 Jun 2026 21:12:50 +0200 Subject: [PATCH 08/10] fix(wallet-cli): use non-deprecated messenger actions in examples and e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AccountsController:listAccounts` (and the rest of the AccountsController read surface) is `@deprecated` in favor of AccountTreeController / Keyring API v2 — neither wired in the CLI — so a CLI caller pointed at a deprecated action with no way to know. Switch the `call` examples and the e2e to `KeyringController:getState`, which is wired, non-deprecated, and exposes the SRP-derived account address directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/src/commands/daemon/call.ts | 4 ++-- packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/wallet-cli/src/commands/daemon/call.ts b/packages/wallet-cli/src/commands/daemon/call.ts index 37a902439b..9961c094ec 100644 --- a/packages/wallet-cli/src/commands/daemon/call.ts +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -10,9 +10,9 @@ 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 KeyringController:getState', '<%= config.bin %> daemon call NetworkController:getState', - '<%= config.bin %> daemon call KeyringController:getState --timeout 10000', + '<%= config.bin %> daemon call ApprovalController:getState --timeout 10000', ]; static override args = { diff --git a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts index cdd7c13348..a8a53498f3 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.e2e.test.ts @@ -23,11 +23,10 @@ describe('createWallet (real Wallet, in-memory)', () => { try { expect(wallet.state.KeyringController?.isUnlocked).toBe(true); - // `listAccounts` resolves synchronously; awaiting a non-thenable trips + // `getState` resolves synchronously; awaiting a non-thenable trips // `@typescript-eslint/await-thenable`. - const accounts = wallet.messenger.call('AccountsController:listAccounts'); - expect(accounts).toHaveLength(1); - expect(accounts[0]?.address).toMatch(/^0x[0-9a-fA-F]{40}$/u); + const { keyrings } = wallet.messenger.call('KeyringController:getState'); + expect(keyrings[0]?.accounts[0]).toMatch(/^0x[0-9a-fA-F]{40}$/u); } finally { await dispose(); } From fd4e99edefaeff7b4d51a0a432b46d42d8dab650 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 25 Jun 2026 21:30:40 +0200 Subject: [PATCH 09/10] fix(wallet-cli): correct Windows dev launcher path and tsx flag `bin/dev.cmd` invoked `node ... "%~dp0\dev"`, but the package only ships `dev.mjs` (run.cmd uses `run.mjs`), so the Windows dev launcher pointed at a nonexistent file. It also registered tsx via `--loader tsx`, which is removed in tsx 4.x; switch to `--import tsx`, matching `daemon-spawn` and the knip note. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/bin/dev.cmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet-cli/bin/dev.cmd b/packages/wallet-cli/bin/dev.cmd index ee0f58bfe9..57f056d96f 100644 --- a/packages/wallet-cli/bin/dev.cmd +++ b/packages/wallet-cli/bin/dev.cmd @@ -1,3 +1,3 @@ @echo off -node --loader tsx --no-warnings=ExperimentalWarning "%~dp0\dev" %* +node --import tsx --no-warnings=ExperimentalWarning "%~dp0\dev.mjs" %* From 1fe01e799516a6873d9ad34a8c75734ce224e050 Mon Sep 17 00:00:00 2001 From: Dimitris Marlagkoutsos Date: Thu, 25 Jun 2026 21:36:53 +0200 Subject: [PATCH 10/10] fix(wallet-cli): make dev mode load TypeScript from src MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev launchers (`bin/dev.mjs` / `bin/dev.cmd`, run with `--import tsx`) put oclif in development mode, but oclif still loaded the compiled `dist/commands` — so dev mode needed a prior build and never picked up source edits. oclif's `tsPath` only rewrites `dist/` to `src/` when the package's `tsconfig.json` declares both `outDir` and `rootDir`; ours had only `baseUrl` (they live in `tsconfig.build.json`, which oclif doesn't read). Add `outDir`/`rootDir` to `tsconfig.json` so dev mode loads `src/**/*.ts` via tsx, matching `@metamask/core-backend`. Verified: with `dist` removed, `node --import tsx bin/dev.mjs daemon call --help` loads the command from source. Build (tsconfig.build.json), eslint, constraints, and tests are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/wallet-cli/tsconfig.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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" },