Skip to content
Merged
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
2 changes: 2 additions & 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 list` command, which prints the messenger actions the running daemon can dispatch via `daemon call`, enumerated from the live messenger so the list cannot drift from what `call` accepts ([#9339](https://github.com/MetaMask/core/pull/9339))
- 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))
Expand All @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Report daemon socket connection errors consistently across `mm daemon call` and `mm daemon list` ([#9339](https://github.com/MetaMask/core/pull/9339))
- Bump `@metamask/wallet` from `^3.0.0` to `^6.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349))

[Unreleased]: https://github.com/MetaMask/core/
12 changes: 10 additions & 2 deletions packages/wallet-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,21 @@ Start the daemon (flags may also be supplied as the `INFURA_PROJECT_ID`, `MM_WAL
mm daemon start --infura-project-id <key> --password <pw> --srp "<phrase>"
```

Discover what the running wallet can do — `list` prints every messenger action currently dispatchable via `call`. This surface grows as more controllers are wired, so treat it as evolving rather than a stability contract:

```sh
mm daemon list
```

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
mm daemon call KeyringController:getState
mm daemon call NetworkController:getState --timeout 10000
```

For the exact parameters and return shape of a given action, see the TypeDoc/README of the controller that owns it (e.g. [`@metamask/keyring-controller`](https://github.com/MetaMask/core/tree/main/packages/keyring-controller#readme)).

Inspect or tear it down:

```sh
Expand Down
19 changes: 7 additions & 12 deletions packages/wallet-cli/src/commands/daemon/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { Args, Command, Flags } from '@oclif/core';

import { sendCommand } from '../../daemon/daemon-client';
import { getDaemonPaths } from '../../daemon/paths';
import { isErrorWithCode } from '../../daemon/utils';
import {
formatJsonRpcError,
makeDaemonConnectionError,
} from '../../daemon/utils';

export default class DaemonCall extends Command {
static override description = 'Call a messenger action on the wallet daemon';
Expand All @@ -18,7 +21,7 @@ export default class DaemonCall extends Command {
static override args = {
action: Args.string({
description:
'The messenger action name (e.g. AccountsController:listAccounts)',
'The messenger action name (e.g. KeyringController:getState)',
required: true,
}),
params: Args.string({
Expand Down Expand Up @@ -70,19 +73,11 @@ export default class DaemonCall extends Command {
...(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));
this.error(makeDaemonConnectionError(error));
}

if (isJsonRpcFailure(response)) {
this.error(
`${response.error.message} (code ${String(response.error.code)})`,
);
this.error(formatJsonRpcError(response.error));
}

const isTTY = process.stdout.isTTY ?? false;
Expand Down
223 changes: 223 additions & 0 deletions packages/wallet-cli/src/commands/daemon/list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { sendCommand } from '../../daemon/daemon-client';
import { runCommand } from '../../test/run-command';
import DaemonList from './list';

jest.mock('../../daemon/daemon-client');

const mockSendCommand = jest.mocked(sendCommand);

/**
* Force `process.stdout.isTTY` for the duration of `fn`, restoring it after.
*
* @param value - The value to assign to `process.stdout.isTTY`.
* @param fn - The callback to run while the override is in place.
*/
async function withTTY(
value: boolean | undefined,
fn: () => Promise<void>,
): Promise<void> {
const original = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', {
value,
configurable: true,
});
try {
await fn();
} finally {
Object.defineProperty(process.stdout, 'isTTY', {
value: original,
configurable: true,
});
}
}

describe('daemon list', () => {
beforeEach(() => {
mockSendCommand.mockResolvedValue({
jsonrpc: '2.0',
id: '1',
result: ['NetworkController:getState', 'KeyringController:getState'],
});
});

it('requests the listActions method', async () => {
await withTTY(true, async () => {
await runCommand(DaemonList);
});

expect(mockSendCommand).toHaveBeenCalledWith(
expect.objectContaining({ method: 'listActions' }),
);
});

it('prints a sorted, indented action list with a count header to a TTY', async () => {
await withTTY(true, async () => {
const { stdout } = await runCommand(DaemonList);

expect(stdout).toContain('2 callable actions');
expect(stdout).toContain('mm daemon call <action>');
// Sorted: KeyringController before NetworkController.
expect(stdout.indexOf('KeyringController:getState')).toBeLessThan(
stdout.indexOf('NetworkController:getState'),
);
expect(stdout).toContain(' KeyringController:getState');
});
});

it('singularizes the header for a single action', async () => {
mockSendCommand.mockResolvedValue({
jsonrpc: '2.0',
id: '1',
result: ['KeyringController:getState'],
});

await withTTY(true, async () => {
const { stdout } = await runCommand(DaemonList);

expect(stdout).toContain('1 callable action ');
});
});

it('reports an empty registry to a TTY', async () => {
mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', result: [] });

await withTTY(true, async () => {
const { stdout } = await runCommand(DaemonList);

expect(stdout).toContain('no callable actions');
});
});

it('writes a bare, sorted, newline-delimited list to a pipe (non-TTY)', async () => {
const writeSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);

await withTTY(false, async () => {
await runCommand(DaemonList);
});

expect(writeSpy).toHaveBeenCalledWith(
'KeyringController:getState\nNetworkController:getState\n',
);
writeSpy.mockRestore();
});

it('sorts actions lexicographically, including within a shared namespace', async () => {
mockSendCommand.mockResolvedValue({
jsonrpc: '2.0',
id: '1',
result: [
'KeyringController:getState',
'AccountsController:listMultichainAccounts',
'KeyringController:addNewAccount',
],
});
const writeSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);

await withTTY(false, async () => {
await runCommand(DaemonList);
});

expect(writeSpy).toHaveBeenCalledWith(
'AccountsController:listMultichainAccounts\n' +
'KeyringController:addNewAccount\n' +
'KeyringController:getState\n',
);
writeSpy.mockRestore();
});

it('treats an undefined isTTY as non-TTY', async () => {
const writeSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);

await withTTY(undefined, async () => {
await runCommand(DaemonList);
});

expect(writeSpy).toHaveBeenCalledWith(
'KeyringController:getState\nNetworkController:getState\n',
);
writeSpy.mockRestore();
});

it('writes nothing to a pipe when the registry is empty', async () => {
mockSendCommand.mockResolvedValue({ jsonrpc: '2.0', id: '1', result: [] });
const writeSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);

await withTTY(false, async () => {
await runCommand(DaemonList);
});

expect(writeSpy).not.toHaveBeenCalled();
writeSpy.mockRestore();
});

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(DaemonList);

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(DaemonList);

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(DaemonList);

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(DaemonList);

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(DaemonList);

expect(error?.message).toContain('Method not found');
expect(error?.message).toContain('-32601');
});

it('errors when the result is not an array of strings', async () => {
mockSendCommand.mockResolvedValue({
jsonrpc: '2.0',
id: '1',
result: { not: 'an array' },
});

const { error } = await runCommand(DaemonList);

expect(error?.message).toContain('unexpected action list');
});
});
61 changes: 61 additions & 0 deletions packages/wallet-cli/src/commands/daemon/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { isJsonRpcFailure } from '@metamask/utils';
import { Command } from '@oclif/core';

import { sendCommand } from '../../daemon/daemon-client';
import { getDaemonPaths } from '../../daemon/paths';
import {
formatJsonRpcError,
isStringArray,
makeDaemonConnectionError,
} from '../../daemon/utils';

export default class DaemonList extends Command {
static override description =
'List the messenger actions the running daemon can dispatch via `daemon call`';

static override examples = ['<%= config.bin %> daemon list'];

public async run(): Promise<void> {
await this.parse(DaemonList);
const { socketPath } = getDaemonPaths(this.config.dataDir);

let response;
try {
response = await sendCommand({ socketPath, method: 'listActions' });
} catch (error) {
this.error(makeDaemonConnectionError(error));
}

if (isJsonRpcFailure(response)) {
this.error(formatJsonRpcError(response.error));
}

if (!isStringArray(response.result)) {
this.error('Daemon returned an unexpected action list.');
}

const actions = [...response.result].sort();

const isTTY = process.stdout.isTTY ?? false;
if (!isTTY) {
// Bare output so it pipes cleanly into `grep`/`fzf`.
if (actions.length > 0) {
process.stdout.write(`${actions.join('\n')}\n`);
}
return;
}

if (actions.length === 0) {
this.log('The daemon has no callable actions registered.');
return;
}

this.log(
`${actions.length} callable action${actions.length === 1 ? '' : 's'} ` +
'(dispatch with `mm daemon call <action>`):',
);
for (const action of actions) {
this.log(` ${action}`);
}
}
}
Loading