Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/wallet-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
27 changes: 27 additions & 0 deletions packages/wallet-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --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 <key> --password <pw> --srp "<phrase>"
```

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`
Expand Down
3 changes: 3 additions & 0 deletions packages/wallet-cli/bin/dev.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off

node --import tsx --no-warnings=ExperimentalWarning "%~dp0\dev.mjs" %*
3 changes: 3 additions & 0 deletions packages/wallet-cli/bin/dev.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { execute } from '@oclif/core';

await execute({ development: true, dir: import.meta.url });
10 changes: 10 additions & 0 deletions packages/wallet-cli/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<rootDir>/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: {
Expand Down
24 changes: 24 additions & 0 deletions packages/wallet-cli/jest.environment.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions packages/wallet-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
152 changes: 152 additions & 0 deletions packages/wallet-cli/src/commands/daemon/call.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
95 changes: 95 additions & 0 deletions packages/wallet-cli/src/commands/daemon/call.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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`);
}
}
}
Loading
Loading