Skip to content
Draft
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 @@ -157,6 +157,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
35 changes: 35 additions & 0 deletions packages/wallet-cli/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Architecture

`@metamask/wallet-cli` is an [oclif](https://oclif.io)-based `mm` CLI that drives a long-lived **daemon** process. The daemon holds an unlocked `@metamask/wallet` `Wallet` in memory and exposes its messenger over a per-user Unix socket, so short-lived CLI invocations can query and mutate wallet state without re-importing the secret recovery phrase on every call.

## Layers

| Layer | Files | Responsibility |
| :------------------ | :-------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Commands** | `src/commands/daemon/{start,stop,status,purge,call}.ts` | Thin oclif command classes. Parse flags/args and call into the daemon layers; no business logic of their own. |
| **Daemon entry** | `src/daemon/daemon-entry.ts` | The detached process's `main`. Claims the singleton slot (PID file + socket), builds the wallet, serves RPC, and tears everything down on signal. |
| **Spawn/lifecycle** | `src/daemon/{daemon-spawn,stop-daemon,utils,paths}.ts` | Start the daemon as a detached child and wait for readiness; stop it (RPC → SIGTERM → SIGKILL); resolve per-user state paths; PID/signal helpers. |
| **Factory** | `src/daemon/wallet-factory.ts` | Construct the `Wallet` with the instance options wired on `@metamask/wallet`, seed it from persisted state, and import the SRP on first run. Returns a `dispose` teardown handle. |
| **Persistence** | `src/persistence/{KeyValueStore,persistence}.ts` | SQLite-backed key-value store for controller state; load persisted (persist-flagged) state and subscribe the store to subsequent state changes. |
| **Transport** | `src/daemon/{rpc-socket-server,daemon-client,socket-line,prompts}.ts` | JSON-RPC over a Unix socket: line-framed read/write, a server hosting the handler map, and a client (`sendCommand`/`pingDaemon`). |

## Flow

```
mm daemon start
└─ ensureDaemon (daemon-spawn) # ping existing socket; if absent, spawn detached daemon-entry
└─ daemon-entry.main
├─ claimDaemonSlot # refuse to clobber a live sibling; write PID file (wx)
├─ createWallet (wallet-factory)
│ ├─ KeyValueStore + loadState (persistence) # rehydrate persist-flagged state
│ ├─ new Wallet({ state, instanceOptions })
│ └─ subscribeToChanges # write controller state through to SQLite
└─ startRpcSocketServer # bind 0o600 socket; expose { getStatus, call }

mm daemon call/status/stop/purge
└─ daemon-client (sendCommand/pingDaemon) / stop-daemon # talk to the socket
```

`call` dispatches an arbitrary `Controller:method` action onto the wallet messenger. Access control is purely filesystem-based: the data directory is `0o700` and the socket is `0o600`, so only the owning user can connect — there is no in-process auth beyond that.

Teardown (signal handler or `dispose`) runs in persistence-safe order: stop the state-change subscription → `await wallet.destroy()` → close the store, then remove the PID file and socket.
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, 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 --loader tsx --no-warnings=ExperimentalWarning "%~dp0\dev" %*
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 });
5 changes: 5 additions & 0 deletions packages/wallet-cli/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions packages/wallet-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
153 changes: 153 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,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,
});
});
});
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 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<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