From 52623e2c2f6f0b04d0e2231ca3f9d6baf6d833ff Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:36:39 -0700 Subject: [PATCH 01/21] chore: Create wallet-cli package --- README.md | 11 ++++ packages/wallet-cli/CHANGELOG.md | 10 ++++ packages/wallet-cli/LICENSE | 20 +++++++ packages/wallet-cli/README.md | 15 ++++++ packages/wallet-cli/jest.config.js | 26 +++++++++ packages/wallet-cli/package.json | 70 +++++++++++++++++++++++++ packages/wallet-cli/src/index.test.ts | 9 ++++ packages/wallet-cli/src/index.ts | 9 ++++ packages/wallet-cli/tsconfig.build.json | 10 ++++ packages/wallet-cli/tsconfig.json | 8 +++ packages/wallet-cli/typedoc.json | 7 +++ tsconfig.build.json | 3 ++ tsconfig.json | 3 ++ yarn.lock | 17 ++++++ 14 files changed, 218 insertions(+) create mode 100644 packages/wallet-cli/CHANGELOG.md create mode 100644 packages/wallet-cli/LICENSE create mode 100644 packages/wallet-cli/README.md create mode 100644 packages/wallet-cli/jest.config.js create mode 100644 packages/wallet-cli/package.json create mode 100644 packages/wallet-cli/src/index.test.ts create mode 100644 packages/wallet-cli/src/index.ts create mode 100644 packages/wallet-cli/tsconfig.build.json create mode 100644 packages/wallet-cli/tsconfig.json create mode 100644 packages/wallet-cli/typedoc.json diff --git a/README.md b/README.md index 13dae34ef03..f907d70a131 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/transaction-pay-controller`](packages/transaction-pay-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) - [`@metamask/wallet`](packages/wallet) +- [`@metamask/wallet-cli`](packages/wallet-cli) @@ -185,6 +186,7 @@ linkStyle default opacity:0.5 transaction_pay_controller(["@metamask/transaction-pay-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); wallet(["@metamask/wallet"]); + wallet_cli(["@metamask/wallet-cli"]); account_tree_controller --> accounts_controller; account_tree_controller --> base_controller; account_tree_controller --> keyring_controller; @@ -522,6 +524,15 @@ linkStyle default opacity:0.5 user_operation_controller --> polling_controller; user_operation_controller --> transaction_controller; user_operation_controller --> eth_block_tracker; + wallet --> accounts_controller; + wallet --> approval_controller; + wallet --> connectivity_controller; + wallet --> controller_utils; + wallet --> keyring_controller; + wallet --> messenger; + wallet --> network_controller; + wallet --> remote_feature_flag_controller; + wallet --> transaction_controller; ``` diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/wallet-cli/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/wallet-cli/LICENSE b/packages/wallet-cli/LICENSE new file mode 100644 index 00000000000..c8a0ff6be3a --- /dev/null +++ b/packages/wallet-cli/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/wallet-cli/README.md b/packages/wallet-cli/README.md new file mode 100644 index 00000000000..1de99a32fa7 --- /dev/null +++ b/packages/wallet-cli/README.md @@ -0,0 +1,15 @@ +# `@metamask/wallet-cli` + +The CLI of @metamask/wallet + +## Installation + +`yarn add @metamask/wallet-cli` + +or + +`npm install @metamask/wallet-cli` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/wallet-cli/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json new file mode 100644 index 00000000000..77d39138380 --- /dev/null +++ b/packages/wallet-cli/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/wallet-cli", + "version": "0.0.0", + "description": "The CLI of @metamask/wallet", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/wallet-cli#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/wallet-cli", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/wallet-cli", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/wallet-cli/src/index.test.ts b/packages/wallet-cli/src/index.test.ts new file mode 100644 index 00000000000..bc062d3694a --- /dev/null +++ b/packages/wallet-cli/src/index.test.ts @@ -0,0 +1,9 @@ +import greeter from '.'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greeter(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/wallet-cli/src/index.ts b/packages/wallet-cli/src/index.ts new file mode 100644 index 00000000000..6972c117292 --- /dev/null +++ b/packages/wallet-cli/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greeter(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/wallet-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json new file mode 100644 index 00000000000..02a0eea03fe --- /dev/null +++ b/packages/wallet-cli/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json new file mode 100644 index 00000000000..025ba2ef7f4 --- /dev/null +++ b/packages/wallet-cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/wallet-cli/typedoc.json b/packages/wallet-cli/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/wallet-cli/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index af5eef1e9a2..16137b2cd77 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -238,6 +238,9 @@ { "path": "./packages/user-operation-controller/tsconfig.build.json" }, + { + "path": "./packages/wallet-cli/tsconfig.build.json" + }, { "path": "./packages/wallet/tsconfig.build.json" } diff --git a/tsconfig.json b/tsconfig.json index cd7ce6f2538..9d1557261df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -229,6 +229,9 @@ }, { "path": "./packages/wallet" + }, + { + "path": "./packages/wallet-cli" } ], "files": [], diff --git a/yarn.lock b/yarn.lock index 60655666277..6fba5e7f490 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5677,6 +5677,23 @@ __metadata: languageName: node linkType: hard +"@metamask/wallet-cli@workspace:packages/wallet-cli": + version: 0.0.0-use.local + resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" From c8c6715c199c9a05e1ae38b313afb1e4e0d47a02 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:29:55 -0700 Subject: [PATCH 02/21] chore: oclif init --- packages/wallet-cli/bin/dev.cmd | 3 + packages/wallet-cli/bin/dev.mjs | 5 + packages/wallet-cli/bin/run.cmd | 3 + packages/wallet-cli/bin/run.mjs | 5 + packages/wallet-cli/package.json | 12 ++ yarn.lock | 192 +++++++++++++++++++++++++++++-- 6 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 packages/wallet-cli/bin/dev.cmd create mode 100755 packages/wallet-cli/bin/dev.mjs create mode 100644 packages/wallet-cli/bin/run.cmd create mode 100755 packages/wallet-cli/bin/run.mjs diff --git a/packages/wallet-cli/bin/dev.cmd b/packages/wallet-cli/bin/dev.cmd new file mode 100644 index 00000000000..ee0f58bfe9b --- /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 00000000000..7be01ea0e7b --- /dev/null +++ b/packages/wallet-cli/bin/dev.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env -S node --loader tsx --disable-warning=ExperimentalWarning + +import { execute } from '@oclif/core'; + +await execute({ development: true, dir: import.meta.url }); diff --git a/packages/wallet-cli/bin/run.cmd b/packages/wallet-cli/bin/run.cmd new file mode 100644 index 00000000000..968fc30758e --- /dev/null +++ b/packages/wallet-cli/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/packages/wallet-cli/bin/run.mjs b/packages/wallet-cli/bin/run.mjs new file mode 100755 index 00000000000..176d2af58c5 --- /dev/null +++ b/packages/wallet-cli/bin/run.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { execute } from '@oclif/core'; + +await execute({ dir: import.meta.url }); diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 77d39138380..3b1154321d6 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -31,6 +31,9 @@ }, "main": "./dist/index.cjs", "types": "./dist/index.d.cts", + "bin": { + "mm": "./bin/run.mjs" + }, "files": [ "dist/" ], @@ -48,6 +51,9 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@oclif/core": "^4.10.5" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", @@ -66,5 +72,11 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "oclif": { + "bin": "mm", + "commands": "./dist/commands", + "dirname": "mm", + "topicSeparator": " " } } diff --git a/yarn.lock b/yarn.lock index 6fba5e7f490..ed6b20f19f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5682,6 +5682,7 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -5691,6 +5692,8 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + bin: + mm: ./bin/mm.js languageName: unknown linkType: soft @@ -5973,6 +5976,32 @@ __metadata: languageName: node linkType: hard +"@oclif/core@npm:^4.10.5": + version: 4.10.5 + resolution: "@oclif/core@npm:4.10.5" + dependencies: + ansi-escapes: "npm:^4.3.2" + ansis: "npm:^3.17.0" + clean-stack: "npm:^3.0.1" + cli-spinners: "npm:^2.9.2" + debug: "npm:^4.4.3" + ejs: "npm:^3.1.10" + get-package-type: "npm:^0.1.0" + indent-string: "npm:^4.0.0" + is-wsl: "npm:^2.2.0" + lilconfig: "npm:^3.1.3" + minimatch: "npm:^10.2.5" + semver: "npm:^7.7.3" + string-width: "npm:^4.2.3" + supports-color: "npm:^8" + tinyglobby: "npm:^0.2.14" + widest-line: "npm:^3.1.0" + wordwrap: "npm:^1.0.0" + wrap-ansi: "npm:^7.0.0" + checksum: 10/c92960f4975675cb8144cf466047be0ffb91fcbe42c0329037f21ab79e61bbd33afb7aacc2d9e1f6293f32af0460941dcaa76166f20505308a9f321baed78b30 + languageName: node + linkType: hard + "@open-rpc/meta-schema@npm:^1.14.6, @open-rpc/meta-schema@npm:^1.14.9": version: 1.14.9 resolution: "@open-rpc/meta-schema@npm:1.14.9" @@ -7202,7 +7231,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.2.1": +"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -7255,6 +7284,13 @@ __metadata: languageName: node linkType: hard +"ansis@npm:^3.17.0": + version: 3.17.0 + resolution: "ansis@npm:3.17.0" + checksum: 10/6fd6bc4d1187b894d9706f4c141c81b788e90766426617385486dae38f8b2f5a1726d8cc754939e44265f92a9db4647d5136cb1425435c39ac42b35e3acf4f3d + languageName: node + linkType: hard + "anymatch@npm:^3.0.3": version: 3.1.3 resolution: "anymatch@npm:3.1.3" @@ -7355,6 +7391,13 @@ __metadata: languageName: node linkType: hard +"async@npm:^3.2.6": + version: 3.2.6 + resolution: "async@npm:3.2.6" + checksum: 10/cb6e0561a3c01c4b56a799cc8bab6ea5fef45f069ab32500b6e19508db270ef2dffa55e5aed5865c5526e9907b1f8be61b27530823b411ffafb5e1538c86c368 + languageName: node + linkType: hard + "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -7476,6 +7519,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + "bare-events@npm:^2.2.0": version: 2.4.2 resolution: "bare-events@npm:2.4.2" @@ -7631,6 +7681,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.5": + version: 5.0.5 + resolution: "brace-expansion@npm:5.0.5" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/f259b2ddf04489da9512ad637ba6b4ef2d77abd4445d20f7f1714585f153435200a53fa6a2e4a5ee974df14ddad4cd16421f6f803e96e8b452bd48598878d0ee + languageName: node + linkType: hard + "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -7924,7 +7983,16 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.6.0": +"clean-stack@npm:^3.0.1": + version: 3.0.1 + resolution: "clean-stack@npm:3.0.1" + dependencies: + escape-string-regexp: "npm:4.0.0" + checksum: 10/dc18c842d7792dd72d463936b1b0a5b2621f0fc11588ee48b602e1a29b6c010c606d89f3de1f95d15d72de74aea93c0fbac8246593a31d95f8462cac36148e05 + languageName: node + linkType: hard + +"cli-spinners@npm:^2.6.0, cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 10/a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794 @@ -8600,6 +8668,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.1.10": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.73": version: 1.5.79 resolution: "electron-to-chromium@npm:1.5.79" @@ -8846,6 +8925,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:4.0.0, escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10/98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 + languageName: node + linkType: hard + "escape-string-regexp@npm:^2.0.0": version: 2.0.0 resolution: "escape-string-regexp@npm:2.0.0" @@ -8853,13 +8939,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 10/98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 - languageName: node - linkType: hard - "escodegen@npm:^2.0.0": version: 2.1.0 resolution: "escodegen@npm:2.1.0" @@ -9684,6 +9763,15 @@ __metadata: languageName: node linkType: hard +"filelist@npm:^1.0.4": + version: 1.0.6 + resolution: "filelist@npm:1.0.6" + dependencies: + minimatch: "npm:^5.0.1" + checksum: 10/84a0be69efe6724c105f18c34e8a772370d9c45e53a1ba8ced7eecf4addd2c5a357347d94bfd8bfa9cbc36b09392cad70d82206305263e26bba184eea4ca8042 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -10577,6 +10665,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^2.0.0": + version: 2.2.1 + resolution: "is-docker@npm:2.2.1" + bin: + is-docker: cli.js + checksum: 10/3fef7ddbf0be25958e8991ad941901bf5922ab2753c46980b60b05c1bf9c9c2402d35e6dc32e4380b980ef5e1970a5d9d5e5aa2e02d77727c3b6b5e918474c56 + languageName: node + linkType: hard + "is-docker@npm:^3.0.0": version: 3.0.0 resolution: "is-docker@npm:3.0.0" @@ -10711,6 +10808,15 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^2.2.0": + version: 2.2.0 + resolution: "is-wsl@npm:2.2.0" + dependencies: + is-docker: "npm:^2.0.0" + checksum: 10/20849846ae414997d290b75e16868e5261e86ff5047f104027026fd61d8b5a9b0b3ade16239f35e1a067b3c7cc02f70183cb661010ed16f4b6c7c93dad1b19d8 + languageName: node + linkType: hard + "is-wsl@npm:^3.1.0": version: 3.1.0 resolution: "is-wsl@npm:3.1.0" @@ -10838,6 +10944,19 @@ __metadata: languageName: node linkType: hard +"jake@npm:^10.8.5": + version: 10.9.4 + resolution: "jake@npm:10.9.4" + dependencies: + async: "npm:^3.2.6" + filelist: "npm:^1.0.4" + picocolors: "npm:^1.1.1" + bin: + jake: bin/cli.js + checksum: 10/97e48f73f5e315a3b6e1a48b4bcc0cdf2c2cf82100ec9e76a032fd5d614dcd32c4315572cfcb66e9f9bdecca3900aaa61fe72b781a74b06aefd3ec4c1c917f0b + languageName: node + linkType: hard + "jest-changed-files@npm:^29.7.0": version: 29.7.0 resolution: "jest-changed-files@npm:29.7.0" @@ -11617,6 +11736,13 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.1.3": + version: 3.1.3 + resolution: "lilconfig@npm:3.1.3" + checksum: 10/b932ce1af94985f0efbe8896e57b1f814a48c8dbd7fc0ef8469785c6303ed29d0090af3ccad7e36b626bfca3a4dc56cc262697e9a8dd867623cf09a39d54e4c3 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -12010,6 +12136,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.5": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10/19e87a931aff60ee7b9d80f39f817b8bfc54f61f8356ee3549fbf636dbccacacfec8d803eac73293955c4527cd085247dfc064bce4a5e349f8f3b85e2bf5da0f + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -12019,6 +12154,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^5.0.1": + version: 5.1.9 + resolution: "minimatch@npm:5.1.9" + dependencies: + brace-expansion: "npm:^2.0.1" + checksum: 10/23b4feb64dcb77ba93b70a72be551eb2e2677ac02178cf1ed3d38836cc4cd84802d90b77f60ef87f2bac64d270d2d8eba242e428f0554ea4e36bfdb7e9d25d0c + languageName: node + linkType: hard + "minimatch@npm:^7.4.6": version: 7.4.6 resolution: "minimatch@npm:7.4.6" @@ -12777,6 +12921,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10/f6ef80a3590827ce20378ae110ac78209cc4f74d39236370f1780f957b7ee41c12acde0e4651b90f39983506fd2f5e449994716f516db2e9752924aff8de93ce + languageName: node + linkType: hard + "pify@npm:^5.0.0": version: 5.0.0 resolution: "pify@npm:5.0.0" @@ -13927,7 +14078,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -14047,7 +14198,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8.0.0": +"supports-color@npm:^8, supports-color@npm:^8.0.0": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -14166,6 +14317,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.14": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10/5c2c41b572ada38449e7c86a5fe034f204a1dbba577225a761a14f29f48dc3f2fc0d81a6c56fcc67c5a742cc3aa9fb5e2ca18dbf22b610b0bc0e549b34d5a0f8 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -14872,6 +15033,15 @@ __metadata: languageName: node linkType: hard +"widest-line@npm:^3.1.0": + version: 3.1.0 + resolution: "widest-line@npm:3.1.0" + dependencies: + string-width: "npm:^4.0.0" + checksum: 10/03db6c9d0af9329c37d74378ff1d91972b12553c7d72a6f4e8525fe61563fa7adb0b9d6e8d546b7e059688712ea874edd5ded475999abdeedf708de9849310e0 + languageName: node + linkType: hard + "word-wrap@npm:^1.2.5": version: 1.2.5 resolution: "word-wrap@npm:1.2.5" From ea7bfc4547ea51e9bfbd97a1777cff3235e744b6 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:27:28 -0700 Subject: [PATCH 03/21] feat(wallet-cli): Implement daemon for running wallet as background process Adds a daemon that runs a Wallet instance as a detached background process, communicating over JSON-RPC via Unix domain sockets. This mirrors the architecture of kernel-cli's daemon. Infrastructure (src/daemon/): - socket-line: newline-delimited socket I/O - rpc-socket-server: generic Unix socket JSON-RPC server - daemon-client: one-shot JSON-RPC client with retry - daemon-entry: standalone entry point for the spawned process - daemon-spawn: spawns daemon-entry as detached child - stop-daemon: shared stop logic with escalation - wallet-factory: creates configured Wallet from config - utils, paths, types: process utilities and path resolution Commands (src/commands/daemon/): - start: start daemon (--infura-project-id or INFURA_PROJECT_ID env) - stop: stop daemon (RPC shutdown -> SIGTERM -> SIGKILL) - status: check daemon status - purge: stop daemon and delete all state files Co-Authored-By: Claude Opus 4.6 --- eslint.config.mjs | 13 ++ packages/wallet-cli/bin/dev.mjs | 2 - packages/wallet-cli/package.json | 5 + .../wallet-cli/src/commands/daemon/purge.ts | 44 ++++ .../wallet-cli/src/commands/daemon/start.ts | 38 ++++ .../wallet-cli/src/commands/daemon/status.ts | 50 +++++ .../wallet-cli/src/commands/daemon/stop.ts | 22 ++ .../wallet-cli/src/daemon/daemon-client.ts | 107 +++++++++ .../wallet-cli/src/daemon/daemon-entry.ts | 116 ++++++++++ .../wallet-cli/src/daemon/daemon-spawn.ts | 78 +++++++ packages/wallet-cli/src/daemon/paths.ts | 17 ++ .../src/daemon/rpc-socket-server.ts | 205 ++++++++++++++++++ packages/wallet-cli/src/daemon/socket-line.ts | 86 ++++++++ packages/wallet-cli/src/daemon/stop-daemon.ts | 75 +++++++ packages/wallet-cli/src/daemon/types.ts | 31 +++ packages/wallet-cli/src/daemon/utils.ts | 120 ++++++++++ .../wallet-cli/src/daemon/wallet-factory.ts | 38 ++++ packages/wallet-cli/tsconfig.build.json | 9 +- packages/wallet-cli/tsconfig.json | 11 +- yarn.lock | 8 +- 20 files changed, 1068 insertions(+), 7 deletions(-) create mode 100644 packages/wallet-cli/src/commands/daemon/purge.ts create mode 100644 packages/wallet-cli/src/commands/daemon/start.ts create mode 100644 packages/wallet-cli/src/commands/daemon/status.ts create mode 100644 packages/wallet-cli/src/commands/daemon/stop.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-client.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-entry.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-spawn.ts create mode 100644 packages/wallet-cli/src/daemon/paths.ts create mode 100644 packages/wallet-cli/src/daemon/rpc-socket-server.ts create mode 100644 packages/wallet-cli/src/daemon/socket-line.ts create mode 100644 packages/wallet-cli/src/daemon/stop-daemon.ts create mode 100644 packages/wallet-cli/src/daemon/types.ts create mode 100644 packages/wallet-cli/src/daemon/utils.ts create mode 100644 packages/wallet-cli/src/daemon/wallet-factory.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 492f1c99c3c..503c5c1a788 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -287,6 +287,19 @@ const config = createConfig([ 'n/no-deprecated-api': 'off', }, }, + { + files: ['packages/wallet-cli/src/**/*.{js,ts}'], + rules: { + 'import-x/no-nodejs-modules': 'off', + 'no-restricted-globals': 'off', + }, + }, + { + files: ['packages/wallet-cli/bin/**/*.mjs'], + rules: { + 'import-x/no-unresolved': 'off', + }, + }, { files: ['packages/messenger/src/generate-action-types/**/*.{js,ts}'], rules: { diff --git a/packages/wallet-cli/bin/dev.mjs b/packages/wallet-cli/bin/dev.mjs index 7be01ea0e7b..857ef9d96b8 100755 --- a/packages/wallet-cli/bin/dev.mjs +++ b/packages/wallet-cli/bin/dev.mjs @@ -1,5 +1,3 @@ -#!/usr/bin/env -S node --loader tsx --disable-warning=ExperimentalWarning - import { execute } from '@oclif/core'; await execute({ development: true, dir: import.meta.url }); diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 3b1154321d6..7400120ce75 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -35,6 +35,7 @@ "mm": "./bin/run.mjs" }, "files": [ + "bin/", "dist/" ], "scripts": { @@ -52,6 +53,10 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/remote-feature-flag-controller": "^4.2.0", + "@metamask/rpc-errors": "^7.0.2", + "@metamask/utils": "^11.9.0", + "@metamask/wallet": "^0.0.0", "@oclif/core": "^4.10.5" }, "devDependencies": { 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 00000000000..6f4f113dbad --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -0,0 +1,44 @@ +import { Command, Flags } from '@oclif/core'; +import { rm } from 'node:fs/promises'; + +import { getDaemonPaths } from '../../daemon/paths'; +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 --force']; + + static override flags = { + force: Flags.boolean({ + char: 'f', + description: 'Required to confirm purge', + required: true, + }), + }; + + public async run(): Promise { + await this.parse(DaemonPurge); + + const { socketPath, pidPath, logPath } = getDaemonPaths( + this.config.dataDir, + ); + + const stopped = await stopDaemon(socketPath, pidPath, (message) => + this.log(message), + ); + + if (!stopped) { + this.error('Refusing to delete state while the daemon is still running.'); + } + + await Promise.all([ + rm(socketPath, { force: true }), + rm(pidPath, { force: true }), + rm(logPath, { force: true }), + ]); + + this.log('All daemon state deleted.'); + } +} 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 00000000000..92712778f66 --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -0,0 +1,38 @@ +import { Command, Flags } from '@oclif/core'; + +import { ensureDaemon } from '../../daemon/daemon-spawn'; +import { getDaemonPaths } from '../../daemon/paths'; + +export default class DaemonStart extends Command { + static override description = 'Start the wallet daemon'; + + static override examples = [ + '<%= config.bin %> daemon start --infura-project-id ', + 'INFURA_PROJECT_ID= <%= 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, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(DaemonStart); + const infuraProjectId = flags['infura-project-id']; + + const { logPath, socketPath } = getDaemonPaths(this.config.dataDir); + + await ensureDaemon(socketPath, { + dataDir: this.config.dataDir, + socketPath, + logPath, + infuraProjectId, + packageRoot: this.config.root, + }); + + this.log(`Daemon running. Socket: ${socketPath}`); + } +} 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 00000000000..d2d144ef1cd --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -0,0 +1,50 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Command } from '@oclif/core'; + +import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; +import { isProcessAlive, 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 processAlive = pid !== undefined && isProcessAlive(pid); + const socketResponsive = await pingDaemon(socketPath); + + if (!processAlive && !socketResponsive) { + this.log('Daemon is not running.'); + return; + } + + if (processAlive && !socketResponsive) { + this.log( + `Daemon process exists (PID: ${pid}) but socket is not responding.`, + ); + return; + } + + const response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 5_000, + }); + + if (isJsonRpcFailure(response)) { + this.log( + `Daemon is running but returned an error: ${response.error.message}`, + ); + return; + } + + const status = response.result as { pid: number; uptime: number }; + this.log( + `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, + ); + } +} 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 00000000000..5df3eb64b1a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/stop.ts @@ -0,0 +1,22 @@ +import { Command } from '@oclif/core'; + +import { getDaemonPaths } from '../../daemon/paths'; +import { stopDaemon } from '../../daemon/stop-daemon'; + +export default class DaemonStop extends Command { + static override description = 'Stop the wallet daemon'; + + static override examples = ['<%= config.bin %> daemon stop']; + + public async run(): Promise { + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); + + const 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/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts new file mode 100644 index 00000000000..68ff9d39d39 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -0,0 +1,107 @@ +import type { JsonRpcResponse } from '@metamask/utils'; +import { assertIsJsonRpcResponse } from '@metamask/utils'; +import { randomUUID } from 'node:crypto'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; + +import { readLine, writeLine } from './socket-line'; + +/** + * Options for {@link sendCommand}. + */ +type SendCommandOptions = { + /** The Unix socket path. */ + socketPath: string; + /** The RPC method name. */ + method: string; + /** Optional method parameters (object or positional array). */ + params?: Record | unknown[] | undefined; + /** Read timeout in milliseconds (default: no timeout). */ + timeoutMs?: number | undefined; +}; + +/** + * Connect to a Unix domain socket. + * + * @param socketPath - The socket path to connect to. + * @returns A connected socket. + */ +async function connectSocket(socketPath: string): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection(socketPath, () => { + socket.removeListener('error', reject); + resolve(socket); + }); + socket.on('error', reject); + }); +} + +/** + * Send a JSON-RPC request to the daemon over a Unix socket and return the + * response. + * + * Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC + * response line, then closes the connection. Retries once after a short delay + * if the connection is rejected. + * + * @param options - Command options. + * @param options.socketPath - The Unix socket path. + * @param options.method - The RPC method name. + * @param options.params - Optional method parameters. + * @param options.timeoutMs - Read timeout in milliseconds. + * @returns The parsed JSON-RPC response. + */ +export async function sendCommand({ + socketPath, + method, + params, + timeoutMs, +}: SendCommandOptions): Promise { + const id = randomUUID(); + const request = { + jsonrpc: '2.0', + id, + method, + ...(params === undefined ? {} : { params }), + }; + + const attempt = async (): Promise => { + const socket = await connectSocket(socketPath); + try { + await writeLine(socket, JSON.stringify(request)); + const responseLine = await readLine(socket, timeoutMs); + const parsed: unknown = JSON.parse(responseLine); + assertIsJsonRpcResponse(parsed); + return parsed; + } finally { + socket.destroy(); + } + }; + + try { + return await attempt(); + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + return attempt(); + } +} + +/** + * Check whether the daemon is running by sending a lightweight `getStatus` + * RPC call. + * + * @param socketPath - The Unix socket path. + * @returns True if the daemon responds to the RPC call. + */ +export async function pingDaemon(socketPath: string): Promise { + try { + await sendCommand({ socketPath, method: 'getStatus', timeoutMs: 3_000 }); + return true; + } catch { + return false; + } +} diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts new file mode 100644 index 00000000000..da3b0c8d505 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -0,0 +1,116 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; + +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import type { RpcHandlerMap } from './types'; +import { createWallet } from './wallet-factory'; + +const startTime = Date.now(); + +main().catch((error: unknown) => { + process.stderr.write(`Daemon fatal: ${String(error)}\n`); + process.exitCode = 1; +}); + +/** + * Main daemon entry point. Starts the daemon process and keeps it running. + */ +async function main(): Promise { + const dataDir = process.env.MM_DAEMON_DATA_DIR; + if (!dataDir) { + throw new Error('MM_DAEMON_DATA_DIR environment variable is required'); + } + + const infuraProjectId = process.env.INFURA_PROJECT_ID; + if (!infuraProjectId) { + throw new Error('INFURA_PROJECT_ID environment variable is required'); + } + + mkdirSync(dataDir, { recursive: true }); + + const { + socketPath: defaultSocketPath, + pidPath, + logPath, + } = getDaemonPaths(dataDir); + const socketPath = process.env.MM_DAEMON_SOCKET_PATH ?? defaultSocketPath; + + const log = makeLogger(logPath); + log('Starting daemon...'); + + const wallet = createWallet({ infuraProjectId }); + + const handlers: RpcHandlerMap = { + getStatus: async () => ({ + pid: process.pid, + uptime: Math.floor((Date.now() - startTime) / 1000), + }), + }; + + let handle: RpcSocketServerHandle; + try { + await writeFile(pidPath, String(process.pid)); + + handle = await startRpcSocketServer({ + socketPath, + handlers, + onShutdown: async () => shutdown('RPC shutdown'), + }); + } catch (error) { + try { + await wallet.destroy(); + } catch { + // Best-effort cleanup. + } + rm(pidPath, { force: true }).catch(() => undefined); + throw error; + } + + log(`Daemon started. Socket: ${socketPath}`); + + let shutdownPromise: Promise | undefined; + + /** + * Shut down the daemon idempotently. Concurrent calls coalesce. + * + * @param reason - A label describing why shutdown was triggered. + * @returns A promise that resolves when shutdown completes. + */ + async function shutdown(reason: string): Promise { + if (shutdownPromise === undefined) { + log(`Shutting down (${reason})...`); + shutdownPromise = (async (): Promise => { + try { + await handle.close(); + await wallet.destroy(); + } finally { + rm(pidPath, { force: true }).catch(() => undefined); + rm(socketPath, { force: true }).catch(() => undefined); + } + })(); + } + return shutdownPromise; + } + + process.on('SIGTERM', () => { + shutdown('SIGTERM').catch(() => (process.exitCode = 1)); + }); + process.on('SIGINT', () => { + shutdown('SIGINT').catch(() => (process.exitCode = 1)); + }); +} + +/** + * Create a simple file logger. + * + * @param logPath - The log file path. + * @returns A logging function. + */ +function makeLogger(logPath: string): (message: string) => void { + return (message: string): void => { + const line = `[${new Date().toISOString()}] ${message}\n`; + appendFileSync(logPath, line); + }; +} diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts new file mode 100644 index 00000000000..c0228d00fc8 --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -0,0 +1,78 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { pingDaemon } from './daemon-client'; +import type { DaemonSpawnConfig } from './types'; + +const POLL_INTERVAL_MS = 100; +const MAX_POLLS = 300; // 30 seconds + +/** + * Ensure the daemon is running. If it is not, spawn it as a detached process + * and wait until the socket becomes responsive. + * + * @param socketPath - The Unix socket path. + * @param config - Spawn configuration. + */ +export async function ensureDaemon( + socketPath: string, + config: DaemonSpawnConfig, +): Promise { + if (await pingDaemon(socketPath)) { + return; + } + + process.stderr.write('Starting daemon...\n'); + + const { entryPath, args } = resolveEntryPoint(config.packageRoot); + + const child = spawn(process.execPath, [...args, entryPath], { + detached: true, + stdio: 'ignore', + env: { + ...process.env, + MM_DAEMON_DATA_DIR: config.dataDir, + MM_DAEMON_SOCKET_PATH: socketPath, + INFURA_PROJECT_ID: config.infuraProjectId, + }, + }); + child.unref(); + + for (let i = 0; i < MAX_POLLS; i++) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + if (await pingDaemon(socketPath)) { + process.stderr.write('Daemon ready.\n'); + return; + } + } + + throw new Error( + `Daemon did not start within ${(MAX_POLLS * POLL_INTERVAL_MS) / 1000}s`, + ); +} + +/** + * Resolve the daemon entry point path and any extra Node.js args needed. + * + * In production, uses the compiled dist output. In development, uses tsx + * to run TypeScript source directly. + * + * @param packageRoot - The root directory of the wallet-cli package. + * @returns The entry path and any extra node args. + */ +function resolveEntryPoint(packageRoot: string): { + entryPath: string; + args: string[]; +} { + const distEntry = join(packageRoot, 'dist', 'daemon', 'daemon-entry.mjs'); + if (existsSync(distEntry)) { + return { entryPath: distEntry, args: [] }; + } + + const srcEntry = join(packageRoot, 'src', 'daemon', 'daemon-entry.ts'); + return { + entryPath: srcEntry, + args: ['--import', 'tsx'], + }; +} diff --git a/packages/wallet-cli/src/daemon/paths.ts b/packages/wallet-cli/src/daemon/paths.ts new file mode 100644 index 00000000000..950fadc2d39 --- /dev/null +++ b/packages/wallet-cli/src/daemon/paths.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path'; + +import type { DaemonPaths } from './types'; + +/** + * Resolve paths for daemon state files within the given data directory. + * + * @param dataDir - The base data directory (e.g. oclif config.dataDir). + * @returns Resolved paths for socket, PID file, and log file. + */ +export function getDaemonPaths(dataDir: string): DaemonPaths { + return { + socketPath: join(dataDir, 'daemon.sock'), + pidPath: join(dataDir, 'daemon.pid'), + logPath: join(dataDir, 'daemon.log'), + }; +} diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts new file mode 100644 index 00000000000..07428b7fd0c --- /dev/null +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -0,0 +1,205 @@ +import { unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import type { Server } from 'node:net'; +import { rpcErrors } from '@metamask/rpc-errors'; +import { hasProperty } from '@metamask/utils'; + +import type { RpcHandlerMap } from './types'; + +/** + * Handle returned by {@link startRpcSocketServer}. + */ +export type RpcSocketServerHandle = { + close: () => Promise; +}; + +/** + * Start a Unix socket server that processes JSON-RPC requests. + * + * Each connection reads one newline-delimited JSON-RPC request, processes it + * via the provided handler map, writes a JSON-RPC response, and closes. + * + * The special `shutdown` method is intercepted before handler dispatch and + * triggers the provided {@link onShutdown} callback after responding. + * + * @param options - Server options. + * @param options.socketPath - The Unix socket path to listen on. + * @param options.handlers - Map of RPC method names to handler functions. + * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. + * @returns A handle with a `close()` function for cleanup. + */ +export async function startRpcSocketServer({ + socketPath, + handlers, + onShutdown, +}: { + socketPath: string; + handlers: RpcHandlerMap; + onShutdown?: (() => Promise) | undefined; +}): Promise { + const server = createServer((socket) => { + let buffer = ''; + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx === -1) { + return; + } + + // One request per connection. + socket.removeListener('data', onData); + + const line = buffer.slice(0, idx); + const remaining = buffer.slice(idx + 1); + buffer = ''; + + if (remaining.length > 0) { + socket.end( + `${JSON.stringify({ + jsonrpc: '2.0', + error: rpcErrors.invalidRequest({ + message: 'Only one request per connection is allowed', + }), + })}\n`, + ); + return; + } + + handleRequest(handlers, line, onShutdown) + .then((response) => { + socket.end(`${JSON.stringify(response)}\n`); + return undefined; + }) + .catch(() => { + socket.end( + `${JSON.stringify({ + jsonrpc: '2.0', + error: rpcErrors.internal({ message: 'Internal error' }), + })}\n`, + ); + }); + }; + socket.on('data', onData); + + socket.on('error', () => { + // Ignore client socket errors (e.g. broken pipe from probe connections). + }); + }); + + await listen(server, socketPath); + + return { + close: async (): Promise => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }, + }; +} + +/** + * Handle a single JSON-RPC request line, intercepting the `shutdown` method. + * + * @param handlers - The RPC handler map. + * @param line - The raw JSON line from the socket. + * @param onShutdown - Optional shutdown callback. + * @returns A JSON-RPC response object. + */ +async function handleRequest( + handlers: RpcHandlerMap, + line: string, + onShutdown?: () => Promise, +): Promise> { + let id: unknown = null; + + try { + const request = JSON.parse(line) as { + id?: unknown; + method?: string; + params?: unknown; + }; + id = request.id ?? null; + const { method } = request; + + if (typeof method !== 'string') { + return { + jsonrpc: '2.0', + id, + error: { code: -32600, message: 'Invalid request: missing method' }, + }; + } + + // Intercept shutdown before handler dispatch. + if (method === 'shutdown') { + if (onShutdown) { + setTimeout(() => { + onShutdown().catch(() => { + // Best-effort shutdown. + }); + }, 0); + } + return { jsonrpc: '2.0', id, result: { status: 'shutting down' } }; + } + + const handler = handlers[method]; + if (!handler) { + return { + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }; + } + + const params = (request.params as Parameters[0]) ?? null; + const result = await handler(params); + return { jsonrpc: '2.0', id, result: result ?? null }; + } catch (error) { + const code = isRpcError(error) ? error.code : -32603; + const message = error instanceof Error ? error.message : 'Internal error'; + return { jsonrpc: '2.0', id, error: { code, message } }; + } +} + +/** + * Check if an error is an RPC error with a numeric code. + * + * @param error - The error to check. + * @returns True if the error has a numeric code property. + */ +function isRpcError(error: unknown): error is { code: number } { + return ( + typeof error === 'object' && + error !== null && + hasProperty(error, 'code') && + typeof (error).code === 'number' + ); +} + +/** + * Start listening on a Unix socket path, removing any stale socket file. + * + * @param server - The net.Server instance. + * @param socketPath - The Unix socket path. + */ +async function listen(server: Server, socketPath: string): Promise { + try { + await unlink(socketPath); + } catch { + // Ignore — file may not exist. + } + + return new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(socketPath, () => { + server.removeListener('error', reject); + resolve(); + }); + }); +} diff --git a/packages/wallet-cli/src/daemon/socket-line.ts b/packages/wallet-cli/src/daemon/socket-line.ts new file mode 100644 index 00000000000..c061174fb83 --- /dev/null +++ b/packages/wallet-cli/src/daemon/socket-line.ts @@ -0,0 +1,86 @@ +import type { Socket } from 'node:net'; + +/** + * Write a newline-delimited line to a socket. + * + * @param socket - The socket to write to. + * @param line - The line to write (without trailing newline). + */ +export async function writeLine(socket: Socket, line: string): Promise { + return new Promise((resolve, reject) => { + socket.write(`${line}\n`, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +/** + * Read a single newline-delimited line from a socket. + * + * @param socket - The socket to read from. + * @param timeoutMs - Optional timeout in milliseconds. Rejects with a timeout + * error if no complete line is received within the limit. + * @returns The line read (without trailing newline). + */ +export async function readLine( + socket: Socket, + timeoutMs?: number, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + let timer: ReturnType | undefined; + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + cleanup(); + reject(new Error('Socket read timed out')); + }, timeoutMs); + } + + const onData = (data: Buffer): void => { + buffer += data.toString(); + const idx = buffer.indexOf('\n'); + if (idx !== -1) { + cleanup(); + resolve(buffer.slice(0, idx)); + } + }; + + const onError = (error: Error): void => { + cleanup(); + reject(error); + }; + + const onEnd = (): void => { + cleanup(); + reject(new Error('Socket closed before response received')); + }; + + const onClose = (): void => { + cleanup(); + reject(new Error('Socket closed before response received')); + }; + + /** + * Remove listeners registered by this call and clear the timeout. + */ + function cleanup(): void { + if (timer !== undefined) { + clearTimeout(timer); + } + socket.removeListener('data', onData); + socket.removeListener('error', onError); + socket.removeListener('end', onEnd); + socket.removeListener('close', onClose); + } + + socket.on('data', onData); + socket.once('error', onError); + socket.once('end', onEnd); + socket.once('close', onClose); + }); +} diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts new file mode 100644 index 00000000000..e0b916281fa --- /dev/null +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -0,0 +1,75 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon, sendCommand } from './daemon-client'; +import { isProcessAlive, readPidFile, waitFor } from './utils'; + +/** + * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if + * the socket is unresponsive, and escalates to SIGKILL if SIGTERM is ignored. + * + * @param socketPath - The daemon socket path. + * @param pidPath - The daemon PID file path. + * @param log - Optional logging function for status messages. + * @returns True if the daemon was stopped (or was not running). + */ +export async function stopDaemon( + socketPath: string, + pidPath: string, + log?: (message: string) => void, +): Promise { + const pid = await readPidFile(pidPath); + const processAlive = pid !== undefined && isProcessAlive(pid); + const socketResponsive = await pingDaemon(socketPath); + + if (!socketResponsive && !processAlive) { + if (pid !== undefined) { + await rm(pidPath, { force: true }); + } + return true; + } + + log?.('Stopping daemon...'); + + let stopped = false; + + // Strategy 1: Graceful socket-based shutdown. + if (socketResponsive) { + try { + await sendCommand({ socketPath, method: 'shutdown' }); + } catch { + // Socket became unresponsive. + } + stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); + } + + // Strategy 2: SIGTERM. + if (!stopped && pid !== undefined) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + } + } + + // Strategy 3: SIGKILL. + if (!stopped && pid !== undefined) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + stopped = true; + } + if (!stopped) { + stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + } + } + + if (stopped) { + await rm(pidPath, { force: true }); + log?.('Daemon stopped.'); + } + + return stopped; +} diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts new file mode 100644 index 00000000000..d4506c7e888 --- /dev/null +++ b/packages/wallet-cli/src/daemon/types.ts @@ -0,0 +1,31 @@ +import type { Json } from '@metamask/utils'; + +/** + * A function that handles a JSON-RPC method call. + */ +export type RpcHandler = (params: Json) => Promise; + +/** + * A map of RPC method names to their handler functions. + */ +export type RpcHandlerMap = Record; + +/** + * Resolved paths for daemon state files. + */ +export type DaemonPaths = { + socketPath: string; + pidPath: string; + logPath: string; +}; + +/** + * Configuration passed to the daemon spawner. + */ +export type DaemonSpawnConfig = { + dataDir: string; + socketPath: string; + logPath: string; + infuraProjectId: string; + packageRoot: string; +}; diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts new file mode 100644 index 00000000000..bda14c1ea72 --- /dev/null +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -0,0 +1,120 @@ +import { hasProperty } from '@metamask/utils'; +import { readFile } from 'node:fs/promises'; + +/** + * Check whether an unknown error is a Node.js system error with the given code. + * + * @param error - The error to check. + * @param code - The expected error code (e.g. 'ENOENT', 'EPERM'). + * @returns True if the error matches the code. + */ +export function isErrorWithCode(error: unknown, code: string): boolean { + return ( + // TODO: use Error.isError() + error instanceof Error && + hasProperty(error, 'code') && + (error).code === code + ); +} + +/** + * Read a PID from a file. + * + * @param pidPath - The PID file path. + * @returns The PID, or undefined if the file is missing or invalid. + */ +export async function readPidFile( + pidPath: string, +): Promise { + try { + const pid = Number(await readFile(pidPath, 'utf-8')); + return pid > 0 && !Number.isNaN(pid) ? pid : undefined; + } catch (error: unknown) { + if (isErrorWithCode(error, 'ENOENT')) { + return undefined; + } + throw error; + } +} + +/** + * Check whether a process is alive by sending signal 0. + * + * @param pid - The process ID to check. + * @returns True if the process exists. + */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + if (isErrorWithCode(error, 'EPERM')) { + return true; + } + return false; + } +} + +/** + * Send a signal to a process. Returns true if the signal was sent, false if + * the process does not exist (ESRCH). Re-throws on permission errors and + * other failures. + * + * @param pid - The process ID. + * @param signal - The signal to send. + * @returns True if the signal was delivered, false if the process is gone. + */ +export function sendSignal(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch (error: unknown) { + if (isErrorWithCode(error, 'ESRCH')) { + return false; + } + throw error; + } +} + +/** + * Poll until a condition is met or the timeout elapses. + * + * @param check - A function that returns true when the condition is met. + * @param timeoutMs - Maximum time to wait in milliseconds. + * @returns True if the condition was met, false on timeout. + */ +export async function waitFor( + check: () => boolean | Promise, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + return await check(); +} + +/** + * Wrap a promise with a timeout rejection. + * + * @param promise - The promise to wrap with a timeout. + * @param timeout - How many ms to wait before rejecting. + * @returns A wrapped promise which rejects after timeout milliseconds. + */ +export async function withTimeout( + promise: Promise, + timeout: number, +): Promise { + return Promise.race([ + promise, + new Promise((_resolve, reject) => + setTimeout( + () => reject(new Error(`promise timed out after ${timeout}ms`)), + timeout, + ), + ), + ]) as Promise; +} diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts new file mode 100644 index 00000000000..8a975e03a6c --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -0,0 +1,38 @@ +import { + ClientConfigApiService, + ClientType, + DistributionType, + EnvironmentType, +} from '@metamask/remote-feature-flag-controller'; +import { Wallet } from '@metamask/wallet'; + +/** + * Create a configured Wallet instance for daemon use. + * + * @param config - Wallet configuration. + * @param config.infuraProjectId - The Infura project ID for network access. + * @returns A new Wallet instance. + */ +export function createWallet({ + infuraProjectId, +}: { + infuraProjectId: string; +}): Wallet { + return new Wallet({ + options: { + infuraProjectId, + clientVersion: '0.0.0', + // TODO: Implement showApprovalRequest + showApprovalRequest: () => undefined, + clientConfigApiService: new ClientConfigApiService({ + fetch: globalThis.fetch, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }), + getMetaMetricsId: () => 'cli', + }, + }); +} diff --git a/packages/wallet-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json index 02a0eea03fe..ac3df52090a 100644 --- a/packages/wallet-cli/tsconfig.build.json +++ b/packages/wallet-cli/tsconfig.build.json @@ -5,6 +5,13 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../wallet/tsconfig.build.json" + } + ], "include": ["../../types", "./src"] } diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json index 025ba2ef7f4..7eb92377c96 100644 --- a/packages/wallet-cli/tsconfig.json +++ b/packages/wallet-cli/tsconfig.json @@ -3,6 +3,13 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], - "include": ["../../types", "./src"] + "references": [ + { + "path": "../remote-feature-flag-controller/tsconfig.json" + }, + { + "path": "../wallet/tsconfig.json" + } + ], + "include": ["../../types", "./bin", "./src"] } diff --git a/yarn.lock b/yarn.lock index ed6b20f19f8..6ac8c9d5aa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5682,6 +5682,10 @@ __metadata: resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/remote-feature-flag-controller": "npm:^4.2.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.9.0" + "@metamask/wallet": "npm:^0.0.0" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" @@ -5693,11 +5697,11 @@ __metadata: typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" bin: - mm: ./bin/mm.js + mm: ./bin/run.mjs languageName: unknown linkType: soft -"@metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^0.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: From cdb851367026d37fdc3c07cc605c296c2678eab1 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:12:53 -0700 Subject: [PATCH 04/21] test(wallet-cli): Add unit tests for daemon modules with 100% coverage 9 test files covering all daemon infrastructure: paths, socket-line, utils, rpc-socket-server, daemon-client, stop-daemon, wallet-factory, daemon-entry, and daemon-spawn. 96 tests total. Config changes: - jest.config.js: exclude commands/ from coverage (not yet tested) - eslint.config.mjs: disable n/no-process-env and n/no-sync for wallet-cli test files Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint.config.mjs | 7 + packages/wallet-cli/jest.config.js | 3 + .../src/daemon/daemon-client.test.ts | 201 +++++++ .../src/daemon/daemon-entry.test.ts | 352 ++++++++++++ .../src/daemon/daemon-spawn.test.ts | 127 +++++ packages/wallet-cli/src/daemon/paths.test.ts | 16 + .../src/daemon/rpc-socket-server.test.ts | 505 ++++++++++++++++++ .../src/daemon/rpc-socket-server.ts | 6 +- .../wallet-cli/src/daemon/socket-line.test.ts | 120 +++++ .../wallet-cli/src/daemon/stop-daemon.test.ts | 187 +++++++ packages/wallet-cli/src/daemon/utils.test.ts | 182 +++++++ packages/wallet-cli/src/daemon/utils.ts | 4 +- .../src/daemon/wallet-factory.test.ts | 28 + 13 files changed, 1732 insertions(+), 6 deletions(-) create mode 100644 packages/wallet-cli/src/daemon/daemon-client.test.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-entry.test.ts create mode 100644 packages/wallet-cli/src/daemon/daemon-spawn.test.ts create mode 100644 packages/wallet-cli/src/daemon/paths.test.ts create mode 100644 packages/wallet-cli/src/daemon/rpc-socket-server.test.ts create mode 100644 packages/wallet-cli/src/daemon/socket-line.test.ts create mode 100644 packages/wallet-cli/src/daemon/stop-daemon.test.ts create mode 100644 packages/wallet-cli/src/daemon/utils.test.ts create mode 100644 packages/wallet-cli/src/daemon/wallet-factory.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 503c5c1a788..86fe4329b2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -294,6 +294,13 @@ const config = createConfig([ 'no-restricted-globals': 'off', }, }, + { + files: ['packages/wallet-cli/src/**/*.test.{js,ts}'], + rules: { + 'n/no-process-env': 'off', + 'n/no-sync': 'off', + }, + }, { files: ['packages/wallet-cli/bin/**/*.mjs'], rules: { diff --git a/packages/wallet-cli/jest.config.js b/packages/wallet-cli/jest.config.js index ca084133399..e863064fbc6 100644 --- a/packages/wallet-cli/jest.config.js +++ b/packages/wallet-cli/jest.config.js @@ -14,6 +14,9 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // TODO: Add tests for commands + coveragePathIgnorePatterns: ['.*/commands/.*'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/wallet-cli/src/daemon/daemon-client.test.ts b/packages/wallet-cli/src/daemon/daemon-client.test.ts new file mode 100644 index 00000000000..a3cf69f717b --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-client.test.ts @@ -0,0 +1,201 @@ +import type { JsonRpcResponse } from '@metamask/utils'; +import { EventEmitter } from 'node:events'; +import { createConnection } from 'node:net'; +import type { Socket } from 'node:net'; + +import { sendCommand, pingDaemon } from './daemon-client'; +import { readLine, writeLine } from './socket-line'; + +jest.mock('node:net'); +jest.mock('./socket-line'); + +const mockCreateConnection = jest.mocked(createConnection); +const mockReadLine = jest.mocked(readLine); +const mockWriteLine = jest.mocked(writeLine); + +/** + * Create a mock Socket and wire up createConnection to return it. + * The connection callback is deferred via process.nextTick to match + * real behavior (the `socket` const must be assigned before the callback + * references it). + * + * @returns The mock socket. + */ +function setupMockSocket(): Socket { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; + + mockCreateConnection.mockImplementation( + (_path: unknown, callback: unknown) => { + process.nextTick(() => (callback as () => void)()); + return socket; + }, + ); + + return socket; +} + +const VALID_RESPONSE: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'test-id', + result: { status: 'ok' }, +}; + +describe('sendCommand', () => { + it('sends a JSON-RPC request and returns the response', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'getStatus', + }); + + expect(mockCreateConnection).toHaveBeenCalledWith( + '/tmp/test.sock', + expect.any(Function), + ); + expect(mockWriteLine).toHaveBeenCalledWith( + socket, + expect.stringContaining('"method":"getStatus"'), + ); + expect(response.result).toStrictEqual({ status: 'ok' }); + expect(socket.destroy).toHaveBeenCalled(); + }); + + it('includes params when provided', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + params: { key: 'value' }, + }); + + const written = mockWriteLine.mock.calls[0][1]; + expect(JSON.parse(written)).toHaveProperty('params', { key: 'value' }); + }); + + it('omits params when undefined', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + const written = mockWriteLine.mock.calls[0][1]; + expect(JSON.parse(written)).not.toHaveProperty('params'); + }); + + it('passes timeoutMs to readLine', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + timeoutMs: 5000, + }); + + expect(mockReadLine).toHaveBeenCalledWith(expect.anything(), 5000); + }); + + it('retries once on ECONNREFUSED', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine + .mockRejectedValueOnce( + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ) + .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + expect(response.result).toStrictEqual({ status: 'ok' }); + expect(socket.destroy).toHaveBeenCalledTimes(2); + }); + + it('retries once on ECONNRESET', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine + .mockRejectedValueOnce( + Object.assign(new Error('reset'), { code: 'ECONNRESET' }), + ) + .mockResolvedValueOnce(JSON.stringify(VALID_RESPONSE)); + + const response = await sendCommand({ + socketPath: '/tmp/test.sock', + method: 'test', + }); + + expect(response).toHaveProperty('result'); + }); + + it('does not retry on other errors', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockRejectedValue(new Error('parse error')); + + await expect( + sendCommand({ socketPath: '/tmp/test.sock', method: 'test' }), + ).rejects.toThrow('parse error'); + + expect(mockReadLine).toHaveBeenCalledTimes(1); + }); + + it('destroys socket even when attempt throws', async () => { + const socket = setupMockSocket(); + mockWriteLine.mockRejectedValue(new Error('write error')); + + await expect( + sendCommand({ socketPath: '/tmp/test.sock', method: 'test' }), + ).rejects.toThrow('write error'); + + expect(socket.destroy).toHaveBeenCalled(); + }); +}); + +describe('pingDaemon', () => { + it('returns true when daemon responds', async () => { + setupMockSocket(); + mockWriteLine.mockResolvedValue(undefined); + mockReadLine.mockResolvedValue(JSON.stringify(VALID_RESPONSE)); + + expect(await pingDaemon('/tmp/test.sock')).toBe(true); + }); + + it('returns false when daemon is unresponsive', async () => { + mockCreateConnection.mockImplementation((_path: unknown) => { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; + process.nextTick(() => + socket.emit( + 'error', + Object.assign(new Error('refused'), { code: 'ECONNREFUSED' }), + ), + ); + return socket; + }); + + expect(await pingDaemon('/tmp/test.sock')).toBe(false); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts new file mode 100644 index 00000000000..64d4fd5937f --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -0,0 +1,352 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; + +import { getDaemonPaths } from './paths'; +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcSocketServerHandle } from './rpc-socket-server'; +import { createWallet } from './wallet-factory'; + +jest.mock('node:fs'); +jest.mock('node:fs/promises'); +jest.mock('./paths'); +jest.mock('./rpc-socket-server'); +jest.mock('./wallet-factory'); + +const mockMkdirSync = jest.mocked(mkdirSync); +const mockAppendFileSync = jest.mocked(appendFileSync); +const mockWriteFile = jest.mocked(writeFile); +const mockRm = jest.mocked(rm); +const mockGetDaemonPaths = jest.mocked(getDaemonPaths); +const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); +const mockCreateWallet = jest.mocked(createWallet); + +// The module under test calls main() at top level on import. +// We use jest.isolateModules to re-import it fresh in each test +// after setting up mocks and env vars. + +const ORIGINAL_ENV = process.env; + +/** + * Create a mock wallet. + * + * @returns A mock wallet object. + */ +function createMockWallet(): ReturnType { + return { + messenger: {} as never, + state: {} as never, + destroy: jest.fn().mockResolvedValue(undefined), + } as unknown as ReturnType; +} + +/** + * Create a mock server handle. + * + * @returns A mock server handle. + */ +function createMockHandle(): RpcSocketServerHandle { + return { close: jest.fn().mockResolvedValue(undefined) }; +} + +describe('daemon-entry', () => { + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + process.exitCode = undefined; + stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + mockGetDaemonPaths.mockReturnValue({ + socketPath: '/tmp/daemon.sock', + pidPath: '/tmp/daemon.pid', + logPath: '/tmp/daemon.log', + }); + mockWriteFile.mockResolvedValue(undefined); + mockRm.mockResolvedValue(undefined); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + process.exitCode = undefined; + }); + + /** + * Import daemon-entry in an isolated module scope so its top-level + * main() runs with the current mocks and env vars. + * Returns after main() settles. + */ + async function importDaemonEntry(): Promise { + await jest.isolateModulesAsync(async () => { + await import('./daemon-entry'); + // Flush microtasks so main()'s .catch() handler settles + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + }); + } + + it('writes to stderr and sets exitCode when MM_DAEMON_DATA_DIR is missing', async () => { + delete process.env.MM_DAEMON_DATA_DIR; + process.env.INFURA_PROJECT_ID = 'key'; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_DAEMON_DATA_DIR'), + ); + expect(process.exitCode).toBe(1); + }); + + it('writes to stderr and sets exitCode when INFURA_PROJECT_ID is missing', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + delete process.env.INFURA_PROJECT_ID; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('INFURA_PROJECT_ID'), + ); + expect(process.exitCode).toBe(1); + }); + + it('creates data dir, wallet, server, and writes PID on successful startup', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { + recursive: true, + }); + expect(mockCreateWallet).toHaveBeenCalledWith({ infuraProjectId: 'key' }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/tmp/daemon.pid', + String(process.pid), + ); + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/tmp/daemon.sock', + }), + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('uses MM_DAEMON_SOCKET_PATH override when set', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + process.env.MM_DAEMON_SOCKET_PATH = '/custom/sock'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + expect(mockStartRpcSocketServer).toHaveBeenCalledWith( + expect.objectContaining({ + socketPath: '/custom/sock', + }), + ); + }); + + it('cleans up wallet and PID file when server fails to start', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + + await importDaemonEntry(); + + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('still cleans up PID when wallet.destroy fails during error cleanup', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + (wallet.destroy as jest.Mock).mockRejectedValue( + new Error('destroy failed'), + ); + mockCreateWallet.mockReturnValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + + await importDaemonEntry(); + + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + expect(process.exitCode).toBe(1); + }); + + it('exposes getStatus handler that returns pid and uptime', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Extract the handlers passed to startRpcSocketServer + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const { handlers } = callArgs; + const status = (await handlers.getStatus(null)) as { + pid: number; + uptime: number; + }; + + expect(status.pid).toBe(process.pid); + expect(typeof status.uptime).toBe('number'); + }); + + it('logs to file via makeLogger', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // makeLogger writes via appendFileSync to the log path + expect(mockAppendFileSync).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('Starting daemon...'), + ); + }); + + it('registers SIGTERM and SIGINT handlers', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + mockCreateWallet.mockReturnValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const registeredEvents = onSpy.mock.calls.map(([event]) => event); + expect(registeredEvents).toContain('SIGTERM'); + expect(registeredEvents).toContain('SIGINT'); + }); + + it('sIGTERM handler calls shutdown and sets exitCode on failure', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + // Find the SIGTERM handler and invoke it + const sigTermCall = onSpy.mock.calls.find(([event]) => event === 'SIGTERM'); + const sigTermHandler = sigTermCall?.[1] as () => void; + sigTermHandler(); + + // Flush promise chain + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(process.exitCode).toBe(1); + }); + + it('sIGINT handler calls shutdown and sets exitCode on failure', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + const onSpy = jest.spyOn(process, 'on'); + + await importDaemonEntry(); + + const sigIntCall = onSpy.mock.calls.find(([event]) => event === 'SIGINT'); + const sigIntHandler = sigIntCall?.[1] as () => void; + sigIntHandler(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(process.exitCode).toBe(1); + }); + + it('handles rm rejection during shutdown cleanup gracefully', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + // rm rejects but cleanup should not fail + mockRm.mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + }); + + it('handles rm rejection in error cleanup path gracefully', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); + mockRm.mockRejectedValue(new Error('rm failed')); + + await importDaemonEntry(); + + expect(process.exitCode).toBe(1); + }); + + it('onShutdown closes server and destroys wallet', async () => { + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + + const wallet = createMockWallet(); + mockCreateWallet.mockReturnValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + // Extract the onShutdown callback + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + + await onShutdown(); + + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts new file mode 100644 index 00000000000..bac8ecfe66c --- /dev/null +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -0,0 +1,127 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +import { pingDaemon } from './daemon-client'; +import { ensureDaemon } from './daemon-spawn'; +import type { DaemonSpawnConfig } from './types'; + +jest.mock('node:child_process'); +jest.mock('node:fs'); +jest.mock('./daemon-client'); + +const mockSpawn = jest.mocked(spawn); +const mockExistsSync = jest.mocked(existsSync); +const mockPingDaemon = jest.mocked(pingDaemon); + +const CONFIG: DaemonSpawnConfig = { + dataDir: '/tmp/data', + socketPath: '/tmp/test.sock', + logPath: '/tmp/daemon.log', + infuraProjectId: 'test-key', + packageRoot: '/pkg', +}; + +describe('ensureDaemon', () => { + beforeEach(() => { + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockSpawn.mockReturnValue({ + unref: jest.fn(), + } as never); + }); + + it('returns immediately if daemon is already running', async () => { + mockPingDaemon.mockResolvedValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('spawns daemon as detached child with correct env vars', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + ['/pkg/dist/daemon/daemon-entry.mjs'], + expect.objectContaining({ + detached: true, + stdio: 'ignore', + env: expect.objectContaining({ + MM_DAEMON_DATA_DIR: '/tmp/data', + MM_DAEMON_SOCKET_PATH: '/tmp/test.sock', + INFURA_PROJECT_ID: 'test-key', + }), + }), + ); + }); + + it('uses dist entry when it exists', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toStrictEqual(['/pkg/dist/daemon/daemon-entry.mjs']); + }); + + it('falls back to src entry with tsx when dist missing', async () => { + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(false); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toStrictEqual([ + '--import', + 'tsx', + '/pkg/src/daemon/daemon-entry.ts', + ]); + }); + + it('polls until daemon is ready', async () => { + mockPingDaemon + .mockResolvedValueOnce(false) // initial check + .mockResolvedValueOnce(false) // poll 1 + .mockResolvedValueOnce(false) // poll 2 + .mockResolvedValueOnce(true); // poll 3 + mockExistsSync.mockReturnValue(true); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + expect(mockPingDaemon).toHaveBeenCalledTimes(4); + expect(process.stderr.write).toHaveBeenCalledWith('Daemon ready.\n'); + }); + + it('throws after timeout when daemon never responds', async () => { + jest.useFakeTimers(); + mockPingDaemon.mockResolvedValue(false); + mockExistsSync.mockReturnValue(true); + + const promise = ensureDaemon('/tmp/test.sock', CONFIG); + // Attach rejection handler before advancing timers to avoid unhandled rejection + const rejection = promise.catch((thrown: unknown) => thrown); + + // Advance past all 300 polls (100ms each = 30s) + await jest.advanceTimersByTimeAsync(30_100); + const thrownError = await rejection; + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe( + 'Daemon did not start within 30s', + ); + jest.useRealTimers(); + }); + + it('calls unref on spawned child', async () => { + const unref = jest.fn(); + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + mockSpawn.mockReturnValue({ unref } as never); + + await ensureDaemon('/tmp/test.sock', CONFIG); + + expect(unref).toHaveBeenCalled(); + }); +}); diff --git a/packages/wallet-cli/src/daemon/paths.test.ts b/packages/wallet-cli/src/daemon/paths.test.ts new file mode 100644 index 00000000000..5ea1f81f092 --- /dev/null +++ b/packages/wallet-cli/src/daemon/paths.test.ts @@ -0,0 +1,16 @@ +import { join } from 'node:path'; + +import { getDaemonPaths } from './paths'; + +describe('getDaemonPaths', () => { + it('returns correct paths for the given data directory', () => { + const dataDir = '/tmp/test-data'; + const paths = getDaemonPaths(dataDir); + + expect(paths).toStrictEqual({ + socketPath: join(dataDir, 'daemon.sock'), + pidPath: join(dataDir, 'daemon.pid'), + logPath: join(dataDir, 'daemon.log'), + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts new file mode 100644 index 00000000000..10446030dea --- /dev/null +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -0,0 +1,505 @@ +import { EventEmitter } from 'node:events'; +import { unlink } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import type { Server, Socket } from 'node:net'; + +import { startRpcSocketServer } from './rpc-socket-server'; +import type { RpcHandlerMap } from './types'; + +jest.mock('node:fs/promises'); +jest.mock('node:net'); + +const mockUnlink = jest.mocked(unlink); +const mockCreateServer = jest.mocked(createServer); + +type ConnectionCallback = (socket: Socket) => void; + +/** + * Flush pending microtasks/promises by awaiting multiple ticks. + */ +async function flushPromises(): Promise { + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } +} + +/** + * Create a mock net.Server. + * + * @returns The mock server and a function to simulate incoming connections. + */ +function createMockServer(): { + server: Server; + simulateConnection: (socket: Socket) => void; +} { + const emitter = new EventEmitter(); + let connectionCallback: ConnectionCallback | undefined; + + const server = Object.assign(emitter, { + listen: jest.fn((_path: string, onListening: () => void) => { + onListening(); + }), + close: jest.fn((onClose: (closeError?: Error) => void) => { + onClose(); + }), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Server; + + mockCreateServer.mockImplementation((handler: unknown) => { + connectionCallback = handler as ConnectionCallback; + return server; + }); + + return { + server, + simulateConnection: (socket: Socket): void => { + connectionCallback?.(socket); + }, + }; +} + +/** + * Create a mock Socket. + * + * @returns A mock socket. + */ +function createMockSocket(): Socket { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + end: jest.fn(), + destroy: jest.fn(), + write: jest.fn(), + removeListener: emitter.removeListener.bind(emitter), + }) as unknown as Socket; +} + +/** + * Parse the JSON-RPC response written to socket.end(). + * + * @param socket - The mock socket. + * @returns The parsed response. + */ +function getResponse(socket: Socket): Record { + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + return JSON.parse(endCall.trim()) as Record; +} + +/** + * Send a JSON-RPC request to a mock socket by emitting data. + * + * @param socket - The mock socket. + * @param request - The request object. + */ +function sendRequest(socket: Socket, request: Record): void { + socket.emit('data', Buffer.from(`${JSON.stringify(request)}\n`)); +} + +describe('startRpcSocketServer', () => { + beforeEach(() => { + mockUnlink.mockResolvedValue(undefined); + }); + + it('removes stale socket file before listening', async () => { + createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + expect(mockUnlink).toHaveBeenCalledWith('/tmp/test.sock'); + }); + + it('ignores unlink errors for missing files', async () => { + mockUnlink.mockRejectedValue(new Error('ENOENT')); + createMockServer(); + + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + expect(handle).toBeDefined(); + }); + + it('returns a handle with close()', async () => { + const { server } = createMockServer(); + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + await handle.close(); + expect(server.close).toHaveBeenCalled(); + }); + + it('rejects close() when server.close errors', async () => { + createMockServer(); + const handle = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const { server } = createMockServer(); + (server.close as jest.Mock).mockImplementation( + (onClose: (closeError?: Error) => void) => { + onClose(new Error('close failed')); + }, + ); + + const handle2 = await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + await expect(handle2.close()).rejects.toThrow('close failed'); + await handle.close(); + }); + + describe('request handling', () => { + it('dispatches valid request to handler and returns result', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + getStatus: jest.fn().mockResolvedValue({ status: 'ok' }), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: '1', + method: 'getStatus', + }); + + await flushPromises(); + + expect(getResponse(socket)).toStrictEqual({ + jsonrpc: '2.0', + id: '1', + result: { status: 'ok' }, + }); + }); + + it('returns null result when handler returns undefined', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + noop: jest.fn().mockResolvedValue(undefined), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'noop' }); + + await flushPromises(); + + expect(getResponse(socket).result).toBeNull(); + }); + + it('returns -32600 for missing method', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32600, + message: 'Invalid request: missing method', + }); + }); + + it('returns -32601 for unknown method', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { + jsonrpc: '2.0', + id: '1', + method: 'nonexistent', + }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32601, + message: 'Method not found: nonexistent', + }); + }); + + it('returns -32603 when handler throws an Error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue(new Error('handler failed')), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32603, + message: 'handler failed', + }); + }); + + it('uses error code when handler throws an RPC error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest + .fn() + .mockRejectedValue( + Object.assign(new Error('custom rpc'), { code: -32001 }), + ), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32001, + message: 'custom rpc', + }); + }); + + it('returns Internal error when handler throws a non-Error value', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue('string error'), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual({ + code: -32603, + message: 'Internal error', + }); + }); + + it('intercepts shutdown method and calls onShutdown', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + const onShutdown = jest.fn().mockResolvedValue(undefined); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + onShutdown, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await jest.advanceTimersByTimeAsync(0); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + expect(onShutdown).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('handles onShutdown rejection gracefully', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + const onShutdown = jest.fn().mockRejectedValue(new Error('shutdown err')); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + onShutdown, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await jest.advanceTimersByTimeAsync(0); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + jest.useRealTimers(); + }); + + it('responds to shutdown even without onShutdown callback', async () => { + const { simulateConnection } = createMockServer(); + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'shutdown' }); + + await flushPromises(); + + expect(getResponse(socket).result).toStrictEqual({ + status: 'shutting down', + }); + }); + + it('rejects multiple requests per connection', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + socket.emit( + 'data', + Buffer.from( + `${JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'a' })}\nextra`, + ), + ); + + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + const response = JSON.parse(endCall.trim()) as Record; + expect(response.error).toBeDefined(); + }); + + it('accumulates partial data across multiple events', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + test: jest.fn().mockResolvedValue('ok'), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + const full = JSON.stringify({ + jsonrpc: '2.0', + id: '1', + method: 'test', + }); + socket.emit('data', Buffer.from(full.slice(0, 10))); + socket.emit('data', Buffer.from(`${full.slice(10)}\n`)); + + await flushPromises(); + + expect(getResponse(socket).result).toBe('ok'); + }); + + it('ignores socket errors', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + // Should not throw + expect(() => + socket.emit('error', new Error('broken pipe')), + ).not.toThrow(); + }); + + it('sends internal error when response serialization fails', async () => { + const { simulateConnection } = createMockServer(); + const circular: Record = {}; + circular.self = circular; + const handlers: RpcHandlerMap = { + bad: jest.fn().mockResolvedValue(circular), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'bad' }); + + await flushPromises(); + + const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; + const response = JSON.parse(endCall.trim()) as Record; + expect(response.error).toBeDefined(); + }); + + it('handles invalid JSON gracefully', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + socket.emit('data', Buffer.from('not-json\n')); + + await flushPromises(); + + expect((getResponse(socket).error as { code: number }).code).toBe(-32603); + }); + + it('uses null id when request has no id', async () => { + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', method: 'unknown' }); + + await flushPromises(); + + expect(getResponse(socket).id).toBeNull(); + }); + }); +}); diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 07428b7fd0c..0fdbdbb319d 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -1,8 +1,8 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import { hasProperty } from '@metamask/utils'; import { unlink } from 'node:fs/promises'; import { createServer } from 'node:net'; import type { Server } from 'node:net'; -import { rpcErrors } from '@metamask/rpc-errors'; -import { hasProperty } from '@metamask/utils'; import type { RpcHandlerMap } from './types'; @@ -178,7 +178,7 @@ function isRpcError(error: unknown): error is { code: number } { typeof error === 'object' && error !== null && hasProperty(error, 'code') && - typeof (error).code === 'number' + typeof error.code === 'number' ); } diff --git a/packages/wallet-cli/src/daemon/socket-line.test.ts b/packages/wallet-cli/src/daemon/socket-line.test.ts new file mode 100644 index 00000000000..b91e4b97245 --- /dev/null +++ b/packages/wallet-cli/src/daemon/socket-line.test.ts @@ -0,0 +1,120 @@ +import { EventEmitter } from 'node:events'; +import type { Socket } from 'node:net'; + +import { readLine, writeLine } from './socket-line'; + +/** + * Create a mock Socket backed by EventEmitter. + * + * @returns A mock socket. + */ +function createMockSocket(): Socket { + const emitter = new EventEmitter(); + const socket = Object.assign(emitter, { + write: jest.fn(), + destroy: jest.fn(), + }); + return socket as unknown as Socket; +} + +describe('writeLine', () => { + it('writes the line with a trailing newline', async () => { + const socket = createMockSocket(); + (socket.write as jest.Mock).mockImplementation( + (_data: string, callback: (writeError?: Error) => void) => callback(), + ); + + await writeLine(socket, 'hello'); + expect(socket.write).toHaveBeenCalledWith('hello\n', expect.any(Function)); + }); + + it('rejects when socket.write returns an error', async () => { + const socket = createMockSocket(); + const writeError = new Error('write failed'); + (socket.write as jest.Mock).mockImplementation( + (_data: string, callback: (e?: Error) => void) => callback(writeError), + ); + + await expect(writeLine(socket, 'hello')).rejects.toThrow('write failed'); + }); +}); + +describe('readLine', () => { + it('resolves with the line when data contains a newline', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hello\n')); + expect(await promise).toBe('hello'); + }); + + it('accumulates data across multiple events', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hel')); + socket.emit('data', Buffer.from('lo\n')); + expect(await promise).toBe('hello'); + }); + + it('rejects on socket error', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('error', new Error('socket error')); + await expect(promise).rejects.toThrow('socket error'); + }); + + it('rejects on socket end', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('end'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects on socket close', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('close'); + await expect(promise).rejects.toThrow( + 'Socket closed before response received', + ); + }); + + it('rejects after timeout when no complete line received', async () => { + jest.useFakeTimers(); + const socket = createMockSocket(); + const promise = readLine(socket, 500); + + jest.advanceTimersByTime(500); + await expect(promise).rejects.toThrow('Socket read timed out'); + jest.useRealTimers(); + }); + + it('resolves before timeout when data arrives in time', async () => { + jest.useFakeTimers(); + const socket = createMockSocket(); + const promise = readLine(socket, 5000); + + socket.emit('data', Buffer.from('hello\n')); + expect(await promise).toBe('hello'); + jest.useRealTimers(); + }); + + it('cleans up listeners after resolving', async () => { + const socket = createMockSocket(); + const promise = readLine(socket); + + socket.emit('data', Buffer.from('hello\n')); + await promise; + + expect(socket.listenerCount('data')).toBe(0); + expect(socket.listenerCount('error')).toBe(0); + expect(socket.listenerCount('end')).toBe(0); + expect(socket.listenerCount('close')).toBe(0); + }); +}); diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts new file mode 100644 index 00000000000..3bb6e0a8eea --- /dev/null +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -0,0 +1,187 @@ +import { rm } from 'node:fs/promises'; + +import { pingDaemon, sendCommand } from './daemon-client'; +import { stopDaemon } from './stop-daemon'; +import { isProcessAlive, readPidFile, waitFor } from './utils'; + +jest.mock('node:fs/promises'); +jest.mock('./daemon-client'); +jest.mock('./utils'); + +const mockRm = jest.mocked(rm); +const mockPingDaemon = jest.mocked(pingDaemon); +const mockSendCommand = jest.mocked(sendCommand); +const mockReadPidFile = jest.mocked(readPidFile); +const mockIsProcessAlive = jest.mocked(isProcessAlive); +const mockWaitFor = jest.mocked(waitFor); + +describe('stopDaemon', () => { + beforeEach(() => { + mockRm.mockResolvedValue(undefined); + }); + + it('returns true when daemon is not running (no PID file)', async () => { + mockReadPidFile.mockResolvedValue(undefined); + mockPingDaemon.mockResolvedValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('cleans up stale PID file when daemon is not running', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(false); + mockPingDaemon.mockResolvedValue(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.pid', { force: true }); + }); + + it('stops daemon via graceful RPC shutdown', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: { status: 'shutting down' }, + }); + // Invoke the check callback for coverage, then return true + mockWaitFor.mockImplementation(async (check) => { + await check(); + return true; + }); + + const log = jest.fn(); + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid', log); + + expect(result).toBe(true); + expect(mockSendCommand).toHaveBeenCalledWith({ + socketPath: '/tmp/test.sock', + method: 'shutdown', + }); + expect(log).toHaveBeenCalledWith('Stopping daemon...'); + expect(log).toHaveBeenCalledWith('Daemon stopped.'); + }); + + it('falls through to SIGTERM when graceful shutdown times out', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + // First waitFor (graceful) invokes cb and fails, second (SIGTERM) invokes cb and succeeds + mockWaitFor + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return true; + }); + jest.spyOn(process, 'kill').mockImplementation(() => true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(process.kill).toHaveBeenCalledWith(123, 'SIGTERM'); + }); + + it('falls through to SIGKILL when SIGTERM times out', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + // All three waitFor calls invoke check, graceful + SIGTERM fail, SIGKILL succeeds + mockWaitFor + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return false; + }) + .mockImplementationOnce(async (check) => { + await check(); + return true; + }); + jest.spyOn(process, 'kill').mockImplementation(() => true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(process.kill).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('returns false when all strategies fail', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + mockWaitFor.mockResolvedValue(false); + jest.spyOn(process, 'kill').mockImplementation(() => true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(false); + }); + + it('treats process.kill throw on SIGTERM as stopped', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + jest.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('process gone'); + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('treats process.kill throw on SIGKILL as stopped', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockResolvedValue({ + jsonrpc: '2.0', + id: '1', + result: null, + }); + // Graceful fails, SIGTERM fails, then SIGKILL throw + mockWaitFor.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + let callCount = 0; + jest.spyOn(process, 'kill').mockImplementation(() => { + callCount += 1; + if (callCount === 2) { + // SIGKILL call + throw new Error('process gone'); + } + return true; + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); + + it('treats sendCommand error as socket unresponsive', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(true); + mockSendCommand.mockRejectedValue(new Error('socket error')); + mockWaitFor.mockResolvedValue(true); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + }); +}); diff --git a/packages/wallet-cli/src/daemon/utils.test.ts b/packages/wallet-cli/src/daemon/utils.test.ts new file mode 100644 index 00000000000..294f65b8eab --- /dev/null +++ b/packages/wallet-cli/src/daemon/utils.test.ts @@ -0,0 +1,182 @@ +import { readFile } from 'node:fs/promises'; + +import { + isErrorWithCode, + isProcessAlive, + readPidFile, + sendSignal, + waitFor, + withTimeout, +} from './utils'; + +jest.mock('node:fs/promises'); + +const mockReadFile = jest.mocked(readFile); + +describe('isErrorWithCode', () => { + it('returns true for an Error with a matching code', () => { + const error = Object.assign(new Error('fail'), { code: 'ENOENT' }); + expect(isErrorWithCode(error, 'ENOENT')).toBe(true); + }); + + it('returns false for an Error with a different code', () => { + const error = Object.assign(new Error('fail'), { code: 'EPERM' }); + expect(isErrorWithCode(error, 'ENOENT')).toBe(false); + }); + + it('returns false for an Error without a code', () => { + expect(isErrorWithCode(new Error('fail'), 'ENOENT')).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isErrorWithCode('not an error', 'ENOENT')).toBe(false); + expect(isErrorWithCode(null, 'ENOENT')).toBe(false); + expect(isErrorWithCode(undefined, 'ENOENT')).toBe(false); + }); +}); + +describe('readPidFile', () => { + it('returns the PID number from a valid file', async () => { + mockReadFile.mockResolvedValue('12345'); + expect(await readPidFile('/tmp/test.pid')).toBe(12345); + }); + + it('returns undefined for ENOENT', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('not found'), { code: 'ENOENT' }), + ); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for NaN content', async () => { + mockReadFile.mockResolvedValue('not-a-number'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for zero', async () => { + mockReadFile.mockResolvedValue('0'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('returns undefined for negative numbers', async () => { + mockReadFile.mockResolvedValue('-1'); + expect(await readPidFile('/tmp/test.pid')).toBeUndefined(); + }); + + it('rethrows non-ENOENT errors', async () => { + mockReadFile.mockRejectedValue( + Object.assign(new Error('permission denied'), { code: 'EACCES' }), + ); + await expect(readPidFile('/tmp/test.pid')).rejects.toThrow( + 'permission denied', + ); + }); +}); + +describe('isProcessAlive', () => { + it('returns true when process.kill(pid, 0) succeeds', () => { + jest.spyOn(process, 'kill').mockImplementation(() => true); + expect(isProcessAlive(123)).toBe(true); + }); + + it('returns true on EPERM (process exists but no permission)', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + expect(isProcessAlive(123)).toBe(true); + }); + + it('returns false on other errors', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('esrch'), { code: 'ESRCH' }); + }); + expect(isProcessAlive(123)).toBe(false); + }); +}); + +describe('sendSignal', () => { + it('returns true when signal is delivered', () => { + jest.spyOn(process, 'kill').mockImplementation(() => true); + expect(sendSignal(123, 'SIGTERM')).toBe(true); + }); + + it('returns false on ESRCH (process gone)', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('esrch'), { code: 'ESRCH' }); + }); + expect(sendSignal(123, 'SIGTERM')).toBe(false); + }); + + it('rethrows other errors', () => { + jest.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + expect(() => sendSignal(123, 'SIGTERM')).toThrow('eperm'); + }); +}); + +describe('waitFor', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns true when check passes immediately', async () => { + expect(await waitFor(() => true, 1000)).toBe(true); + }); + + it('returns true when check passes after polling', async () => { + let calls = 0; + const check = (): boolean => { + calls += 1; + return calls >= 3; + }; + + const promise = waitFor(check, 5000); + await jest.advanceTimersByTimeAsync(500); + expect(await promise).toBe(true); + }); + + it('returns false on timeout', async () => { + const promise = waitFor(() => false, 500); + await jest.advanceTimersByTimeAsync(750); + expect(await promise).toBe(false); + }); + + it('works with async check functions', async () => { + let calls = 0; + const check = async (): Promise => { + calls += 1; + return calls >= 2; + }; + + const promise = waitFor(check, 5000); + await jest.advanceTimersByTimeAsync(500); + expect(await promise).toBe(true); + }); +}); + +describe('withTimeout', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves with value if promise resolves before timeout', async () => { + expect(await withTimeout(Promise.resolve('result'), 1000)).toBe('result'); + }); + + it('rejects with timeout error if timeout fires first', async () => { + const neverResolves = new Promise(() => undefined); + const promise = withTimeout(neverResolves, 500); + + jest.advanceTimersByTime(500); + await expect(promise).rejects.toThrow('promise timed out after 500ms'); + }); +}); diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index bda14c1ea72..66f73ee5561 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -11,9 +11,7 @@ import { readFile } from 'node:fs/promises'; export function isErrorWithCode(error: unknown, code: string): boolean { return ( // TODO: use Error.isError() - error instanceof Error && - hasProperty(error, 'code') && - (error).code === code + error instanceof Error && hasProperty(error, 'code') && error.code === code ); } diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts new file mode 100644 index 00000000000..bb573b91f54 --- /dev/null +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -0,0 +1,28 @@ +import { Wallet } from '@metamask/wallet'; + +import { createWallet } from './wallet-factory'; + +jest.mock('@metamask/wallet'); +jest.mock('@metamask/remote-feature-flag-controller'); + +const MockWallet = jest.mocked(Wallet); + +describe('createWallet', () => { + it('instantiates Wallet with the given infuraProjectId', () => { + createWallet({ infuraProjectId: 'test-key' }); + + expect(MockWallet).toHaveBeenCalledTimes(1); + const args = MockWallet.mock.calls[0][0]; + expect(args.options.infuraProjectId).toBe('test-key'); + }); + + it('uses expected default options', () => { + createWallet({ infuraProjectId: 'test-key' }); + + const args = MockWallet.mock.calls[0][0]; + expect(args.options.clientVersion).toBe('0.0.0'); + expect(args.options.showApprovalRequest()).toBeUndefined(); + expect(args.options.getMetaMetricsId()).toBe('cli'); + expect(args.options.clientConfigApiService).toBeDefined(); + }); +}); From 3b5e6fadc03e97e08b7816cd2993baaa0e440df4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:41:46 -0700 Subject: [PATCH 05/21] fix: Fix error serialization in rpc socket server --- .../src/daemon/rpc-socket-server.test.ts | 49 ++++++++++--------- .../src/daemon/rpc-socket-server.ts | 30 +++++++++--- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 10446030dea..9ff8e82a7a9 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -214,10 +214,12 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32600, - message: 'Invalid request: missing method', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32600, + message: 'Invalid request: missing method', + }), + ); }); it('returns -32601 for unknown method', async () => { @@ -237,10 +239,12 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32601, - message: 'Method not found: nonexistent', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32601, + message: 'Method not found: nonexistent', + }), + ); }); it('returns -32603 when handler throws an Error', async () => { @@ -260,20 +264,19 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32603, - message: 'handler failed', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'handler failed', + }), + ); }); - it('uses error code when handler throws an RPC error', async () => { + it('passes through RPC error objects when handler throws one', async () => { const { simulateConnection } = createMockServer(); + const rpcError = { code: -32001, message: 'custom rpc' }; const handlers: RpcHandlerMap = { - failing: jest - .fn() - .mockRejectedValue( - Object.assign(new Error('custom rpc'), { code: -32001 }), - ), + failing: jest.fn().mockRejectedValue(rpcError), }; await startRpcSocketServer({ @@ -310,10 +313,12 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect(getResponse(socket).error).toStrictEqual({ - code: -32603, - message: 'Internal error', - }); + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'Internal error', + }), + ); }); it('intercepts shutdown method and calls onShutdown', async () => { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 0fdbdbb319d..4600fa3fd34 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -58,9 +58,11 @@ export async function startRpcSocketServer({ socket.end( `${JSON.stringify({ jsonrpc: '2.0', - error: rpcErrors.invalidRequest({ - message: 'Only one request per connection is allowed', - }), + error: rpcErrors + .invalidRequest({ + message: 'Only one request per connection is allowed', + }) + .serialize(), })}\n`, ); return; @@ -75,7 +77,9 @@ export async function startRpcSocketServer({ socket.end( `${JSON.stringify({ jsonrpc: '2.0', - error: rpcErrors.internal({ message: 'Internal error' }), + error: rpcErrors + .internal({ message: 'Internal error' }) + .serialize(), })}\n`, ); }); @@ -132,7 +136,9 @@ async function handleRequest( return { jsonrpc: '2.0', id, - error: { code: -32600, message: 'Invalid request: missing method' }, + error: rpcErrors + .invalidRequest({ message: 'Invalid request: missing method' }) + .serialize(), }; } @@ -153,7 +159,9 @@ async function handleRequest( return { jsonrpc: '2.0', id, - error: { code: -32601, message: `Method not found: ${method}` }, + error: rpcErrors + .methodNotFound({ message: `Method not found: ${method}` }) + .serialize(), }; } @@ -161,9 +169,15 @@ async function handleRequest( const result = await handler(params); return { jsonrpc: '2.0', id, result: result ?? null }; } catch (error) { - const code = isRpcError(error) ? error.code : -32603; + if (isRpcError(error)) { + return { jsonrpc: '2.0', id, error }; + } const message = error instanceof Error ? error.message : 'Internal error'; - return { jsonrpc: '2.0', id, error: { code, message } }; + return { + jsonrpc: '2.0', + id, + error: rpcErrors.internal({ message }).serialize(), + }; } } From 047087dd302cf436fb673c5a55387bc5762bc42c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:48:36 -0700 Subject: [PATCH 06/21] fix(wallet-cli): Use sendSignal in stopDaemon, remove dead withTimeout Wire sendSignal into stopDaemon so EPERM errors from process.kill are not silently treated as successful stops. Remove unused withTimeout utility. Co-Authored-By: Claude Opus 4.6 --- .../wallet-cli/src/daemon/stop-daemon.test.ts | 71 +++++++++++-------- packages/wallet-cli/src/daemon/stop-daemon.ts | 24 ++++--- packages/wallet-cli/src/daemon/utils.test.ts | 23 ------ packages/wallet-cli/src/daemon/utils.ts | 22 ------ 4 files changed, 56 insertions(+), 84 deletions(-) diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index 3bb6e0a8eea..d03fd6cc115 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -2,7 +2,7 @@ import { rm } from 'node:fs/promises'; import { pingDaemon, sendCommand } from './daemon-client'; import { stopDaemon } from './stop-daemon'; -import { isProcessAlive, readPidFile, waitFor } from './utils'; +import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; jest.mock('node:fs/promises'); jest.mock('./daemon-client'); @@ -13,6 +13,7 @@ const mockPingDaemon = jest.mocked(pingDaemon); const mockSendCommand = jest.mocked(sendCommand); const mockReadPidFile = jest.mocked(readPidFile); const mockIsProcessAlive = jest.mocked(isProcessAlive); +const mockSendSignal = jest.mocked(sendSignal); const mockWaitFor = jest.mocked(waitFor); describe('stopDaemon', () => { @@ -74,6 +75,7 @@ describe('stopDaemon', () => { id: '1', result: null, }); + mockSendSignal.mockReturnValue(true); // First waitFor (graceful) invokes cb and fails, second (SIGTERM) invokes cb and succeeds mockWaitFor .mockImplementationOnce(async (check) => { @@ -84,11 +86,10 @@ describe('stopDaemon', () => { await check(); return true; }); - jest.spyOn(process, 'kill').mockImplementation(() => true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); - expect(process.kill).toHaveBeenCalledWith(123, 'SIGTERM'); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGTERM'); }); it('falls through to SIGKILL when SIGTERM times out', async () => { @@ -100,6 +101,7 @@ describe('stopDaemon', () => { id: '1', result: null, }); + mockSendSignal.mockReturnValue(true); // All three waitFor calls invoke check, graceful + SIGTERM fail, SIGKILL succeeds mockWaitFor .mockImplementationOnce(async (check) => { @@ -114,11 +116,10 @@ describe('stopDaemon', () => { await check(); return true; }); - jest.spyOn(process, 'kill').mockImplementation(() => true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); - expect(process.kill).toHaveBeenCalledWith(123, 'SIGKILL'); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); }); it('returns false when all strategies fail', async () => { @@ -130,48 +131,62 @@ describe('stopDaemon', () => { id: '1', result: null, }); + mockSendSignal.mockReturnValue(true); mockWaitFor.mockResolvedValue(false); - jest.spyOn(process, 'kill').mockImplementation(() => true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(false); }); - it('treats process.kill throw on SIGTERM as stopped', async () => { + it('treats ESRCH on SIGTERM as stopped', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); mockPingDaemon.mockResolvedValue(false); - jest.spyOn(process, 'kill').mockImplementation(() => { - throw new Error('process gone'); - }); + mockSendSignal.mockReturnValue(false); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); }); - it('treats process.kill throw on SIGKILL as stopped', async () => { + it('treats ESRCH on SIGKILL as stopped', async () => { mockReadPidFile.mockResolvedValue(123); mockIsProcessAlive.mockReturnValue(true); - mockPingDaemon.mockResolvedValue(true); - mockSendCommand.mockResolvedValue({ - jsonrpc: '2.0', - id: '1', - result: null, - }); - // Graceful fails, SIGTERM fails, then SIGKILL throw - mockWaitFor.mockResolvedValueOnce(false).mockResolvedValueOnce(false); - let callCount = 0; - jest.spyOn(process, 'kill').mockImplementation(() => { - callCount += 1; - if (callCount === 2) { - // SIGKILL call - throw new Error('process gone'); - } - return true; - }); + mockPingDaemon.mockResolvedValue(false); + // SIGTERM signal sent but process doesn't die, SIGKILL finds it gone + mockSendSignal.mockReturnValueOnce(true).mockReturnValueOnce(false); + mockWaitFor.mockResolvedValueOnce(false); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('falls through to SIGKILL when SIGTERM throws EPERM', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + mockSendSignal + .mockImplementationOnce(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }) + .mockReturnValueOnce(true); + mockWaitFor.mockResolvedValueOnce(true); const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); expect(result).toBe(true); + expect(mockSendSignal).toHaveBeenCalledWith(123, 'SIGKILL'); + }); + + it('returns false when both SIGTERM and SIGKILL throw EPERM', async () => { + mockReadPidFile.mockResolvedValue(123); + mockIsProcessAlive.mockReturnValue(true); + mockPingDaemon.mockResolvedValue(false); + mockSendSignal.mockImplementation(() => { + throw Object.assign(new Error('eperm'), { code: 'EPERM' }); + }); + + const result = await stopDaemon('/tmp/test.sock', '/tmp/test.pid'); + expect(result).toBe(false); }); it('treats sendCommand error as socket unresponsive', async () => { diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts index e0b916281fa..24a47f158cf 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -1,7 +1,7 @@ import { rm } from 'node:fs/promises'; import { pingDaemon, sendCommand } from './daemon-client'; -import { isProcessAlive, readPidFile, waitFor } from './utils'; +import { isProcessAlive, readPidFile, sendSignal, waitFor } from './utils'; /** * Stop the daemon via a `shutdown` RPC call. Falls back to PID + SIGTERM if @@ -45,24 +45,26 @@ export async function stopDaemon( // Strategy 2: SIGTERM. if (!stopped && pid !== undefined) { try { - process.kill(pid, 'SIGTERM'); + if (sendSignal(pid, 'SIGTERM')) { + stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + } else { + stopped = true; // Process already gone (ESRCH). + } } catch { - stopped = true; - } - if (!stopped) { - stopped = await waitFor(() => !isProcessAlive(pid), 5_000); + // Permission error — fall through to next strategy. } } // Strategy 3: SIGKILL. if (!stopped && pid !== undefined) { try { - process.kill(pid, 'SIGKILL'); + if (sendSignal(pid, 'SIGKILL')) { + stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + } else { + stopped = true; // Process already gone (ESRCH). + } } catch { - stopped = true; - } - if (!stopped) { - stopped = await waitFor(() => !isProcessAlive(pid), 2_000); + // Permission error — cannot kill process. } } diff --git a/packages/wallet-cli/src/daemon/utils.test.ts b/packages/wallet-cli/src/daemon/utils.test.ts index 294f65b8eab..78b678078f4 100644 --- a/packages/wallet-cli/src/daemon/utils.test.ts +++ b/packages/wallet-cli/src/daemon/utils.test.ts @@ -6,7 +6,6 @@ import { readPidFile, sendSignal, waitFor, - withTimeout, } from './utils'; jest.mock('node:fs/promises'); @@ -158,25 +157,3 @@ describe('waitFor', () => { expect(await promise).toBe(true); }); }); - -describe('withTimeout', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('resolves with value if promise resolves before timeout', async () => { - expect(await withTimeout(Promise.resolve('result'), 1000)).toBe('result'); - }); - - it('rejects with timeout error if timeout fires first', async () => { - const neverResolves = new Promise(() => undefined); - const promise = withTimeout(neverResolves, 500); - - jest.advanceTimersByTime(500); - await expect(promise).rejects.toThrow('promise timed out after 500ms'); - }); -}); diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index 66f73ee5561..793eab67dfd 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -94,25 +94,3 @@ export async function waitFor( } return await check(); } - -/** - * Wrap a promise with a timeout rejection. - * - * @param promise - The promise to wrap with a timeout. - * @param timeout - How many ms to wait before rejecting. - * @returns A wrapped promise which rejects after timeout milliseconds. - */ -export async function withTimeout( - promise: Promise, - timeout: number, -): Promise { - return Promise.race([ - promise, - new Promise((_resolve, reject) => - setTimeout( - () => reject(new Error(`promise timed out after ${timeout}ms`)), - timeout, - ), - ), - ]) as Promise; -} From 65d33cdcaba46c65ed010dac9ce157c7b2db4dda Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:04:55 -0700 Subject: [PATCH 07/21] feat(wallet-cli): Add password and SRP flags to daemon start Thread wallet password and secret recovery phrase through the daemon startup chain so the wallet is initialized with an imported SRP. - Export importSecretRecoveryPhrase from @metamask/wallet - Add --password and --srp required flags to `daemon start` - Pass MM_WALLET_PASSWORD / MM_WALLET_SRP env vars to spawned daemon - Make createWallet async; call importSecretRecoveryPhrase after init Co-Authored-By: Claude Opus 4.6 --- .../wallet-cli/src/commands/daemon/start.ts | 17 +++- .../src/daemon/daemon-entry.test.ts | 98 +++++++++---------- .../wallet-cli/src/daemon/daemon-entry.ts | 12 ++- .../src/daemon/daemon-spawn.test.ts | 5 + .../wallet-cli/src/daemon/daemon-spawn.ts | 2 + packages/wallet-cli/src/daemon/types.ts | 2 + .../src/daemon/wallet-factory.test.ts | 32 +++++- .../wallet-cli/src/daemon/wallet-factory.ts | 20 +++- packages/wallet/src/index.ts | 1 + 9 files changed, 124 insertions(+), 65 deletions(-) diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index 92712778f66..6b467f761d0 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -7,8 +7,8 @@ export default class DaemonStart extends Command { static override description = 'Start the wallet daemon'; static override examples = [ - '<%= config.bin %> daemon start --infura-project-id ', - 'INFURA_PROJECT_ID= <%= config.bin %> daemon start', + '<%= 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 = { @@ -17,11 +17,22 @@ export default class DaemonStart extends Command { env: 'INFURA_PROJECT_ID', required: true, }), + password: Flags.string({ + description: 'Wallet password', + env: 'MM_WALLET_PASSWORD', + required: true, + }), + srp: Flags.string({ + description: 'Secret recovery phrase (BIP-39 mnemonic)', + 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 { logPath, socketPath } = getDaemonPaths(this.config.dataDir); @@ -30,6 +41,8 @@ export default class DaemonStart extends Command { socketPath, logPath, infuraProjectId, + password, + srp, packageRoot: this.config.root, }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 64d4fd5937f..33581954d7a 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -31,12 +31,12 @@ const ORIGINAL_ENV = process.env; * * @returns A mock wallet object. */ -function createMockWallet(): ReturnType { +function createMockWallet(): Awaited> { return { messenger: {} as never, state: {} as never, destroy: jest.fn().mockResolvedValue(undefined), - } as unknown as ReturnType; + } as unknown as Awaited>; } /** @@ -53,6 +53,11 @@ describe('daemon-entry', () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; + process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; + process.env.INFURA_PROJECT_ID = 'key'; + process.env.MM_WALLET_PASSWORD = 'pass'; + process.env.MM_WALLET_SRP = + 'test test test test test test test test test test test ball'; process.exitCode = undefined; stderrSpy = jest .spyOn(process.stderr, 'write') @@ -89,7 +94,6 @@ describe('daemon-entry', () => { it('writes to stderr and sets exitCode when MM_DAEMON_DATA_DIR is missing', async () => { delete process.env.MM_DAEMON_DATA_DIR; - process.env.INFURA_PROJECT_ID = 'key'; await importDaemonEntry(); @@ -100,7 +104,6 @@ describe('daemon-entry', () => { }); it('writes to stderr and sets exitCode when INFURA_PROJECT_ID is missing', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; delete process.env.INFURA_PROJECT_ID; await importDaemonEntry(); @@ -111,12 +114,31 @@ describe('daemon-entry', () => { expect(process.exitCode).toBe(1); }); - it('creates data dir, wallet, server, and writes PID on successful startup', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; + it('writes to stderr and sets exitCode when MM_WALLET_PASSWORD is missing', async () => { + delete process.env.MM_WALLET_PASSWORD; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_PASSWORD'), + ); + expect(process.exitCode).toBe(1); + }); + it('writes to stderr and sets exitCode when MM_WALLET_SRP is missing', async () => { + delete process.env.MM_WALLET_SRP; + + await importDaemonEntry(); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('MM_WALLET_SRP'), + ); + expect(process.exitCode).toBe(1); + }); + + it('creates data dir, wallet, server, and writes PID on successful startup', async () => { const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -125,7 +147,11 @@ describe('daemon-entry', () => { expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/data', { recursive: true, }); - expect(mockCreateWallet).toHaveBeenCalledWith({ infuraProjectId: 'key' }); + expect(mockCreateWallet).toHaveBeenCalledWith({ + infuraProjectId: 'key', + password: 'pass', + srp: 'test test test test test test test test test test test ball', + }); expect(mockWriteFile).toHaveBeenCalledWith( '/tmp/daemon.pid', String(process.pid), @@ -139,11 +165,9 @@ describe('daemon-entry', () => { }); it('uses MM_DAEMON_SOCKET_PATH override when set', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; process.env.MM_DAEMON_SOCKET_PATH = '/custom/sock'; - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -156,11 +180,8 @@ describe('daemon-entry', () => { }); it('cleans up wallet and PID file when server fails to start', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); await importDaemonEntry(); @@ -171,14 +192,11 @@ describe('daemon-entry', () => { }); it('still cleans up PID when wallet.destroy fails during error cleanup', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); (wallet.destroy as jest.Mock).mockRejectedValue( new Error('destroy failed'), ); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); await importDaemonEntry(); @@ -188,10 +206,7 @@ describe('daemon-entry', () => { }); it('exposes getStatus handler that returns pid and uptime', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -209,10 +224,7 @@ describe('daemon-entry', () => { }); it('logs to file via makeLogger', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); await importDaemonEntry(); @@ -225,10 +237,7 @@ describe('daemon-entry', () => { }); it('registers SIGTERM and SIGINT handlers', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - - mockCreateWallet.mockReturnValue(createMockWallet()); + mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); const onSpy = jest.spyOn(process, 'on'); @@ -241,11 +250,8 @@ describe('daemon-entry', () => { }); it('sIGTERM handler calls shutdown and sets exitCode on failure', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -268,11 +274,8 @@ describe('daemon-entry', () => { }); it('sIGINT handler calls shutdown and sets exitCode on failure', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); @@ -293,11 +296,8 @@ describe('daemon-entry', () => { }); it('handles rm rejection during shutdown cleanup gracefully', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); // rm rejects but cleanup should not fail @@ -315,11 +315,8 @@ describe('daemon-entry', () => { }); it('handles rm rejection in error cleanup path gracefully', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); mockStartRpcSocketServer.mockRejectedValue(new Error('server failed')); mockRm.mockRejectedValue(new Error('rm failed')); @@ -329,11 +326,8 @@ describe('daemon-entry', () => { }); it('onShutdown closes server and destroys wallet', async () => { - process.env.MM_DAEMON_DATA_DIR = '/tmp/data'; - process.env.INFURA_PROJECT_ID = 'key'; - const wallet = createMockWallet(); - mockCreateWallet.mockReturnValue(wallet); + mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); mockStartRpcSocketServer.mockResolvedValue(handle); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index da3b0c8d505..c48a9919c7d 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -28,6 +28,16 @@ async function main(): Promise { throw new Error('INFURA_PROJECT_ID environment variable is required'); } + const password = process.env.MM_WALLET_PASSWORD; + if (!password) { + throw new Error('MM_WALLET_PASSWORD environment variable is required'); + } + + const srp = process.env.MM_WALLET_SRP; + if (!srp) { + throw new Error('MM_WALLET_SRP environment variable is required'); + } + mkdirSync(dataDir, { recursive: true }); const { @@ -40,7 +50,7 @@ async function main(): Promise { const log = makeLogger(logPath); log('Starting daemon...'); - const wallet = createWallet({ infuraProjectId }); + const wallet = await createWallet({ infuraProjectId, password, srp }); const handlers: RpcHandlerMap = { getStatus: async () => ({ diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index bac8ecfe66c..4585510eb95 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -18,6 +18,8 @@ const CONFIG: DaemonSpawnConfig = { socketPath: '/tmp/test.sock', logPath: '/tmp/daemon.log', infuraProjectId: 'test-key', + password: 'test-pass', + srp: 'test test test test test test test test test test test ball', packageRoot: '/pkg', }; @@ -52,6 +54,9 @@ describe('ensureDaemon', () => { MM_DAEMON_DATA_DIR: '/tmp/data', MM_DAEMON_SOCKET_PATH: '/tmp/test.sock', INFURA_PROJECT_ID: 'test-key', + MM_WALLET_PASSWORD: 'test-pass', + MM_WALLET_SRP: + 'test test test test test test test test test test test ball', }), }), ); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index c0228d00fc8..79f6ad43a08 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -35,6 +35,8 @@ export async function ensureDaemon( MM_DAEMON_DATA_DIR: config.dataDir, MM_DAEMON_SOCKET_PATH: socketPath, INFURA_PROJECT_ID: config.infuraProjectId, + MM_WALLET_PASSWORD: config.password, + MM_WALLET_SRP: config.srp, }, }); child.unref(); diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index d4506c7e888..1c8e8e8dbed 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -27,5 +27,7 @@ export type DaemonSpawnConfig = { socketPath: string; logPath: string; infuraProjectId: string; + password: string; + srp: string; packageRoot: string; }; diff --git a/packages/wallet-cli/src/daemon/wallet-factory.test.ts b/packages/wallet-cli/src/daemon/wallet-factory.test.ts index bb573b91f54..16898475912 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.test.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.test.ts @@ -1,4 +1,4 @@ -import { Wallet } from '@metamask/wallet'; +import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; import { createWallet } from './wallet-factory'; @@ -6,18 +6,25 @@ jest.mock('@metamask/wallet'); jest.mock('@metamask/remote-feature-flag-controller'); const MockWallet = jest.mocked(Wallet); +const mockImportSrp = jest.mocked(importSecretRecoveryPhrase); + +const CONFIG = { + infuraProjectId: 'test-key', + password: 'test-pass', + srp: 'test test test test test test test test test test test ball', +}; describe('createWallet', () => { - it('instantiates Wallet with the given infuraProjectId', () => { - createWallet({ infuraProjectId: 'test-key' }); + it('instantiates Wallet with the given infuraProjectId', async () => { + await createWallet(CONFIG); expect(MockWallet).toHaveBeenCalledTimes(1); const args = MockWallet.mock.calls[0][0]; expect(args.options.infuraProjectId).toBe('test-key'); }); - it('uses expected default options', () => { - createWallet({ infuraProjectId: 'test-key' }); + it('uses expected default options', async () => { + await createWallet(CONFIG); const args = MockWallet.mock.calls[0][0]; expect(args.options.clientVersion).toBe('0.0.0'); @@ -25,4 +32,19 @@ describe('createWallet', () => { expect(args.options.getMetaMetricsId()).toBe('cli'); expect(args.options.clientConfigApiService).toBeDefined(); }); + + it('imports the secret recovery phrase with the given password', async () => { + await createWallet(CONFIG); + + expect(mockImportSrp).toHaveBeenCalledWith( + expect.any(Wallet), + 'test-pass', + 'test test test test test test test test test test test ball', + ); + }); + + it('returns the wallet instance', async () => { + const wallet = await createWallet(CONFIG); + expect(wallet).toBeInstanceOf(Wallet); + }); }); diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 8a975e03a6c..7f98995e65e 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -4,21 +4,27 @@ import { DistributionType, EnvironmentType, } from '@metamask/remote-feature-flag-controller'; -import { Wallet } from '@metamask/wallet'; +import { importSecretRecoveryPhrase, Wallet } from '@metamask/wallet'; /** * Create a configured Wallet instance for daemon use. * * @param config - Wallet configuration. * @param config.infuraProjectId - The Infura project ID for network access. - * @returns A new Wallet instance. + * @param config.password - The wallet password. + * @param config.srp - The secret recovery phrase (BIP-39 mnemonic). + * @returns A new Wallet instance with the SRP imported. */ -export function createWallet({ +export async function createWallet({ infuraProjectId, + password, + srp, }: { infuraProjectId: string; -}): Wallet { - return new Wallet({ + password: string; + srp: string; +}): Promise { + const wallet = new Wallet({ options: { infuraProjectId, clientVersion: '0.0.0', @@ -35,4 +41,8 @@ export function createWallet({ getMetaMetricsId: () => 'cli', }, }); + + await importSecretRecoveryPhrase(wallet, password, srp); + + return wallet; } diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts index a3db3b1b449..3b1b41def3b 100644 --- a/packages/wallet/src/index.ts +++ b/packages/wallet/src/index.ts @@ -1 +1,2 @@ export { Wallet } from './Wallet'; +export { importSecretRecoveryPhrase } from './utilities'; From d1e0c3710456a771441271be76d354d864d2c8ad Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:26:06 -0700 Subject: [PATCH 08/21] feat(wallet-cli): Add daemon call command for invoking messenger actions Adds a `daemon call` CLI command that forwards arbitrary messenger action calls to the running wallet daemon over JSON-RPC. The daemon registers a `call` RPC handler that invokes `wallet.messenger.call()` with the provided action name and arguments. Usage: wallet-cli daemon call AccountsController:listAccounts wallet-cli daemon call NetworkController:getState --timeout 10000 Also fixes lint errors in wallet-factory.ts (missing return types). Co-Authored-By: Claude Opus 4.6 (1M context) --- eslint.config.mjs | 1 + .../wallet-cli/src/commands/daemon/call.ts | 80 ++++++++++++++++++ .../src/daemon/daemon-entry.test.ts | 84 ++++++++++++++++++- .../wallet-cli/src/daemon/daemon-entry.ts | 7 ++ .../wallet-cli/src/daemon/wallet-factory.ts | 4 +- 5 files changed, 173 insertions(+), 3 deletions(-) create mode 100644 packages/wallet-cli/src/commands/daemon/call.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 86fe4329b2e..4ebdcdd548f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -297,6 +297,7 @@ const config = createConfig([ { files: ['packages/wallet-cli/src/**/*.test.{js,ts}'], rules: { + 'jest/unbound-method': 'off', 'n/no-process-env': 'off', 'n/no-sync': 'off', }, 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 00000000000..8feac20ff7a --- /dev/null +++ b/packages/wallet-cli/src/commands/daemon/call.ts @@ -0,0 +1,80 @@ +import { isJsonRpcFailure } from '@metamask/utils'; +import { Args, Command, Flags } from '@oclif/core'; + +import { sendCommand } from '../../daemon/daemon-client'; +import { getDaemonPaths } from '../../daemon/paths'; + +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; + + // Build the params array for the `call` RPC method: [action, ...args] + let rpcParams: unknown[] = [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 = [action, ...parsed]; + } + + const { socketPath } = getDaemonPaths(this.config.dataDir); + + const response = await sendCommand({ + socketPath, + method: 'call', + params: rpcParams, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + + 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/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 33581954d7a..5f9845870d9 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -33,7 +33,7 @@ const ORIGINAL_ENV = process.env; */ function createMockWallet(): Awaited> { return { - messenger: {} as never, + messenger: { call: jest.fn() } as never, state: {} as never, destroy: jest.fn().mockResolvedValue(undefined), } as unknown as Awaited>; @@ -343,4 +343,86 @@ describe('daemon-entry', () => { expect(wallet.destroy).toHaveBeenCalled(); expect(mockRm).toHaveBeenCalledWith('/tmp/daemon.pid', { force: true }); }); + + describe('call handler', () => { + /** + * Import the daemon entry and extract the `call` handler from the + * handlers map, along with the mock wallet for assertions. + * + * @returns The call handler function and mock wallet. + */ + async function setupCallHandler(): Promise<{ + callHandler: (params: unknown) => Promise; + wallet: Awaited>; + }> { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const callHandler = callArgs.handlers.call as ( + params: unknown, + ) => Promise; + return { callHandler, wallet }; + } + + it('registers a call handler', async () => { + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + expect(typeof callArgs.handlers.call).toBe('function'); + }); + + it('forwards action and args to messenger.call', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue({ accounts: [] }); + + const result = await callHandler(['Controller:action', 'arg1', 'arg2']); + + expect(mockCall).toHaveBeenCalledWith( + 'Controller:action', + 'arg1', + 'arg2', + ); + expect(result).toStrictEqual({ accounts: [] }); + }); + + it('calls messenger.call with no extra args when only action is provided', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockReturnValue('ok'); + + await callHandler(['Controller:action']); + + expect(mockCall).toHaveBeenCalledWith('Controller:action'); + }); + + it('awaits async messenger.call results', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockResolvedValue({ async: true }); + + const result = await callHandler(['Controller:asyncAction']); + + expect(result).toStrictEqual({ async: true }); + }); + + it('propagates errors thrown by messenger.call', async () => { + const { callHandler, wallet } = await setupCallHandler(); + const mockCall = wallet.messenger.call as jest.Mock; + mockCall.mockImplementation(() => { + throw new Error('A handler for Unknown:action has not been registered'); + }); + + await expect(callHandler(['Unknown:action'])).rejects.toThrow( + 'A handler for Unknown:action has not been registered', + ); + }); + }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index c48a9919c7d..09bc1a2e8b2 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -1,3 +1,4 @@ +import type { Json } from '@metamask/utils'; import { appendFileSync, mkdirSync } from 'node:fs'; import { rm, writeFile } from 'node:fs/promises'; @@ -57,6 +58,12 @@ async function main(): Promise { pid: process.pid, uptime: Math.floor((Date.now() - startTime) / 1000), }), + call: async (params) => { + const [action, ...args] = params as [string, ...Json[]]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. + const result = (wallet.messenger as any).call(action, ...args); + return (result instanceof Promise ? await result : result) as Json; + }, }; let handle: RpcSocketServerHandle; diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 7f98995e65e..7707d73a672 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -29,7 +29,7 @@ export async function createWallet({ infuraProjectId, clientVersion: '0.0.0', // TODO: Implement showApprovalRequest - showApprovalRequest: () => undefined, + showApprovalRequest: (): undefined => undefined, clientConfigApiService: new ClientConfigApiService({ fetch: globalThis.fetch, config: { @@ -38,7 +38,7 @@ export async function createWallet({ environment: EnvironmentType.Production, }, }), - getMetaMetricsId: () => 'cli', + getMetaMetricsId: (): string => 'cli', }, }); From 2e9c53c67c096a686284c4efcd89b0066e4371c8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:34:55 -0700 Subject: [PATCH 09/21] chore: Fix codeowners --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7da370ce3fe..f45980f2e23 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -118,6 +118,8 @@ /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/wallet @MetaMask/core-platform @rekmarks @FrederikBolding +/packages/wallet-cli @MetaMask/core-platform @rekmarks @FrederikBolding ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform From 37945de0bce5098e899a60946d4270a495940580 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:01:42 -0700 Subject: [PATCH 10/21] fix(wallet-cli): Address PR review findings - Remove placeholder index.ts and library exports (CLI-only package) - Add yarn constraints exception for wallet-cli exports - Document intentional `as any` messenger dispatch in daemon-entry - Remove duplicate socketPath param from ensureDaemon - Remove unused logPath from DaemonSpawnConfig - Add 30s server-side socket read timeout in rpc-socket-server - Handle sendCommand throwing in status command - Have purge remove entire data directory - Tighten isRpcError to require both code and message - Fix waitFor to return false on timeout instead of re-checking - Clarify multi-request rejection test assertions - Document password/srp CLI flags as testing-only Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wallet-cli/package.json | 12 ---- .../wallet-cli/src/commands/daemon/purge.ts | 10 +--- .../wallet-cli/src/commands/daemon/start.ts | 12 ++-- .../wallet-cli/src/commands/daemon/status.ts | 16 +++-- .../wallet-cli/src/daemon/daemon-entry.ts | 3 + .../src/daemon/daemon-spawn.test.ts | 15 +++-- .../wallet-cli/src/daemon/daemon-spawn.ts | 7 +-- .../src/daemon/rpc-socket-server.test.ts | 59 ++++++++++++++++++- .../src/daemon/rpc-socket-server.ts | 17 +++++- packages/wallet-cli/src/daemon/types.ts | 1 - packages/wallet-cli/src/daemon/utils.ts | 2 +- packages/wallet-cli/src/index.test.ts | 9 --- packages/wallet-cli/src/index.ts | 9 --- yarn.config.cjs | 3 +- 14 files changed, 106 insertions(+), 69 deletions(-) delete mode 100644 packages/wallet-cli/src/index.test.ts delete mode 100644 packages/wallet-cli/src/index.ts diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 7400120ce75..edbd684cb08 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -17,20 +17,8 @@ "license": "MIT", "sideEffects": false, "exports": { - ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, "./package.json": "./package.json" }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.cts", "bin": { "mm": "./bin/run.mjs" }, diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index 6f4f113dbad..cb4e969fff0 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -21,9 +21,7 @@ export default class DaemonPurge extends Command { public async run(): Promise { await this.parse(DaemonPurge); - const { socketPath, pidPath, logPath } = getDaemonPaths( - this.config.dataDir, - ); + const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); const stopped = await stopDaemon(socketPath, pidPath, (message) => this.log(message), @@ -33,11 +31,7 @@ export default class DaemonPurge extends Command { this.error('Refusing to delete state while the daemon is still running.'); } - await Promise.all([ - rm(socketPath, { force: true }), - rm(pidPath, { force: true }), - rm(logPath, { force: true }), - ]); + await rm(this.config.dataDir, { recursive: true, force: true }); this.log('All daemon state deleted.'); } diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index 6b467f761d0..61771e5658f 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -11,6 +11,7 @@ export default class DaemonStart extends Command { 'INFURA_PROJECT_ID= MM_WALLET_PASSWORD= MM_WALLET_SRP= <%= config.bin %> daemon start', ]; + // TODO: Delete unsafe flags static override flags = { 'infura-project-id': Flags.string({ description: 'Infura project ID for network access', @@ -18,12 +19,14 @@ export default class DaemonStart extends Command { required: true, }), password: Flags.string({ - description: 'Wallet password', + 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 (BIP-39 mnemonic)', + description: + 'Secret recovery phrase (testing only — use MM_WALLET_SRP env var in production)', env: 'MM_WALLET_SRP', required: true, }), @@ -34,12 +37,11 @@ export default class DaemonStart extends Command { const infuraProjectId = flags['infura-project-id']; const { password, srp } = flags; - const { logPath, socketPath } = getDaemonPaths(this.config.dataDir); + const { socketPath } = getDaemonPaths(this.config.dataDir); - await ensureDaemon(socketPath, { + await ensureDaemon({ dataDir: this.config.dataDir, socketPath, - logPath, infuraProjectId, password, srp, diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts index d2d144ef1cd..38f30f479a3 100644 --- a/packages/wallet-cli/src/commands/daemon/status.ts +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -29,11 +29,17 @@ export default class DaemonStatus extends Command { return; } - const response = await sendCommand({ - socketPath, - method: 'getStatus', - timeoutMs: 5_000, - }); + let response; + try { + response = await sendCommand({ + socketPath, + method: 'getStatus', + timeoutMs: 5_000, + }); + } catch { + this.log('Daemon socket is responsive but status request failed.'); + return; + } if (isJsonRpcFailure(response)) { this.log( diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 09bc1a2e8b2..37b555f525e 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -58,6 +58,9 @@ async function main(): Promise { pid: process.pid, uptime: Math.floor((Date.now() - startTime) / 1000), }), + // Arbitrary messenger dispatch is intentional: the CLI exposes the full + // messenger surface over a local Unix socket. Access control is enforced + // at the socket level (only local users can connect). call: async (params) => { const [action, ...args] = params as [string, ...Json[]]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index 4585510eb95..a8d74d96124 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -16,7 +16,6 @@ const mockPingDaemon = jest.mocked(pingDaemon); const CONFIG: DaemonSpawnConfig = { dataDir: '/tmp/data', socketPath: '/tmp/test.sock', - logPath: '/tmp/daemon.log', infuraProjectId: 'test-key', password: 'test-pass', srp: 'test test test test test test test test test test test ball', @@ -34,7 +33,7 @@ describe('ensureDaemon', () => { it('returns immediately if daemon is already running', async () => { mockPingDaemon.mockResolvedValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(mockSpawn).not.toHaveBeenCalled(); }); @@ -42,7 +41,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(mockSpawn).toHaveBeenCalledWith( process.execPath, @@ -66,7 +65,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).toStrictEqual(['/pkg/dist/daemon/daemon-entry.mjs']); @@ -76,7 +75,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(false); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; expect(spawnArgs).toStrictEqual([ @@ -94,7 +93,7 @@ describe('ensureDaemon', () => { .mockResolvedValueOnce(true); // poll 3 mockExistsSync.mockReturnValue(true); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(mockPingDaemon).toHaveBeenCalledTimes(4); expect(process.stderr.write).toHaveBeenCalledWith('Daemon ready.\n'); @@ -105,7 +104,7 @@ describe('ensureDaemon', () => { mockPingDaemon.mockResolvedValue(false); mockExistsSync.mockReturnValue(true); - const promise = ensureDaemon('/tmp/test.sock', CONFIG); + const promise = ensureDaemon(CONFIG); // Attach rejection handler before advancing timers to avoid unhandled rejection const rejection = promise.catch((thrown: unknown) => thrown); @@ -125,7 +124,7 @@ describe('ensureDaemon', () => { mockExistsSync.mockReturnValue(true); mockSpawn.mockReturnValue({ unref } as never); - await ensureDaemon('/tmp/test.sock', CONFIG); + await ensureDaemon(CONFIG); expect(unref).toHaveBeenCalled(); }); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index 79f6ad43a08..75d1611b8a9 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -12,13 +12,10 @@ const MAX_POLLS = 300; // 30 seconds * Ensure the daemon is running. If it is not, spawn it as a detached process * and wait until the socket becomes responsive. * - * @param socketPath - The Unix socket path. * @param config - Spawn configuration. */ -export async function ensureDaemon( - socketPath: string, - config: DaemonSpawnConfig, -): Promise { +export async function ensureDaemon(config: DaemonSpawnConfig): Promise { + const { socketPath } = config; if (await pingDaemon(socketPath)) { return; } diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 9ff8e82a7a9..7633477db36 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -396,6 +396,8 @@ describe('startRpcSocketServer', () => { const socket = createMockSocket(); simulateConnection(socket); + + // Send a valid request followed by extra data after the newline. socket.emit( 'data', Buffer.from( @@ -403,9 +405,13 @@ describe('startRpcSocketServer', () => { ), ); - const endCall = (socket.end as jest.Mock).mock.calls[0][0] as string; - const response = JSON.parse(endCall.trim()) as Record; - expect(response.error).toBeDefined(); + const response = getResponse(socket); + expect(response.error).toStrictEqual( + expect.objectContaining({ + code: -32600, + message: 'Only one request per connection is allowed', + }), + ); }); it('accumulates partial data across multiple events', async () => { @@ -506,5 +512,52 @@ describe('startRpcSocketServer', () => { expect(getResponse(socket).id).toBeNull(); }); + + it('destroys socket when no complete request arrives within timeout', async () => { + jest.useFakeTimers(); + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + // Send partial data (no newline). + socket.emit('data', Buffer.from('partial')); + + expect(socket.destroy).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(socket.destroy).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + it('wraps thrown object with code but no message as internal error', async () => { + const { simulateConnection } = createMockServer(); + const handlers: RpcHandlerMap = { + failing: jest.fn().mockRejectedValue({ code: 42 }), + }; + + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + sendRequest(socket, { jsonrpc: '2.0', id: '1', method: 'failing' }); + + await flushPromises(); + + expect(getResponse(socket).error).toStrictEqual( + expect.objectContaining({ + code: -32603, + message: 'Internal error', + }), + ); + }); }); }); diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 4600fa3fd34..12491b80a7b 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -28,6 +28,8 @@ export type RpcSocketServerHandle = { * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. * @returns A handle with a `close()` function for cleanup. */ +const CONNECTION_TIMEOUT_MS = 30_000; + export async function startRpcSocketServer({ socketPath, handlers, @@ -40,6 +42,11 @@ export async function startRpcSocketServer({ const server = createServer((socket) => { let buffer = ''; + // Destroy connections that never send a complete request line. + const timer = setTimeout(() => { + socket.destroy(); + }, CONNECTION_TIMEOUT_MS); + const onData = (data: Buffer): void => { buffer += data.toString(); const idx = buffer.indexOf('\n'); @@ -47,6 +54,8 @@ export async function startRpcSocketServer({ return; } + clearTimeout(timer); + // One request per connection. socket.removeListener('data', onData); @@ -187,12 +196,16 @@ async function handleRequest( * @param error - The error to check. * @returns True if the error has a numeric code property. */ -function isRpcError(error: unknown): error is { code: number } { +function isRpcError( + error: unknown, +): error is { code: number; message: string } { return ( typeof error === 'object' && error !== null && hasProperty(error, 'code') && - typeof error.code === 'number' + typeof error.code === 'number' && + hasProperty(error, 'message') && + typeof error.message === 'string' ); } diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 1c8e8e8dbed..714f6e81a93 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -25,7 +25,6 @@ export type DaemonPaths = { export type DaemonSpawnConfig = { dataDir: string; socketPath: string; - logPath: string; infuraProjectId: string; password: string; srp: string; diff --git a/packages/wallet-cli/src/daemon/utils.ts b/packages/wallet-cli/src/daemon/utils.ts index 793eab67dfd..3e1db9cdc04 100644 --- a/packages/wallet-cli/src/daemon/utils.ts +++ b/packages/wallet-cli/src/daemon/utils.ts @@ -92,5 +92,5 @@ export async function waitFor( } await new Promise((resolve) => setTimeout(resolve, 250)); } - return await check(); + return false; } diff --git a/packages/wallet-cli/src/index.test.ts b/packages/wallet-cli/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/wallet-cli/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/wallet-cli/src/index.ts b/packages/wallet-cli/src/index.ts deleted file mode 100644 index 6972c117292..00000000000 --- a/packages/wallet-cli/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} diff --git a/yarn.config.cjs b/yarn.config.cjs index 78612e4699e..3f7411e2060 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -119,7 +119,8 @@ module.exports = defineConfig({ // exports correctly. if ( workspace.ident !== '@metamask/foundryup' && - workspace.ident !== '@metamask/messenger-cli' + workspace.ident !== '@metamask/messenger-cli' && + workspace.ident !== '@metamask/wallet-cli' ) { expectCorrectWorkspaceExports(workspace); } From ae5e5b528d94e343293366071226b46db82d61ea Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:04:10 -0700 Subject: [PATCH 11/21] fix(wallet-cli): Validate params in call RPC handler Destructuring null params (when a request omits the params field) would throw a confusing TypeError. Add a runtime check that params is a non-empty array before destructuring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wallet-cli/src/daemon/daemon-entry.test.ts | 16 ++++++++++++++++ packages/wallet-cli/src/daemon/daemon-entry.ts | 3 +++ 2 files changed, 19 insertions(+) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 5f9845870d9..9920e2333c0 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -424,5 +424,21 @@ describe('daemon-entry', () => { 'A handler for Unknown:action has not been registered', ); }); + + it('throws when params is null', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler(null)).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); + + it('throws when params is an empty array', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 37b555f525e..bcde91b89d2 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -62,6 +62,9 @@ async function main(): Promise { // messenger surface over a local Unix socket. Access control is enforced // at the socket level (only local users can connect). call: async (params) => { + if (!Array.isArray(params) || params.length === 0) { + throw new Error('Expected params to be an array with an action name'); + } const [action, ...args] = params as [string, ...Json[]]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The messenger is strongly typed; we bypass it here to dispatch arbitrary action names from RPC. const result = (wallet.messenger as any).call(action, ...args); From ab0ed78dfd884cc6b7030d2f4a4d2de334a04dde Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:07:39 -0700 Subject: [PATCH 12/21] chore: yarn dedupe --- yarn.lock | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6ac8c9d5aa8..1ef65b7ace0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12918,13 +12918,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 - languageName: node - linkType: hard - "picomatch@npm:^4.0.4": version: 4.0.4 resolution: "picomatch@npm:4.0.4" @@ -14321,7 +14314,7 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.14": +"tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": version: 0.2.16 resolution: "tinyglobby@npm:0.2.16" dependencies: @@ -14331,16 +14324,6 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2 - languageName: node - linkType: hard - "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" From c1eb9f67422edb339e79c768fcec1fa4ebb62084 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:10:47 -0700 Subject: [PATCH 13/21] docs: Update readme dep graph --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f907d70a131..dd9845358a0 100644 --- a/README.md +++ b/README.md @@ -533,6 +533,8 @@ linkStyle default opacity:0.5 wallet --> network_controller; wallet --> remote_feature_flag_controller; wallet --> transaction_controller; + wallet_cli --> remote_feature_flag_controller; + wallet_cli --> wallet; ``` From 1c1a4bf6b6887d1e6452524eedc3826b5f425d7b Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:30:50 -0700 Subject: [PATCH 14/21] chore: prettier --- packages/wallet-cli/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index edbd684cb08..62674f030cb 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -40,6 +40,12 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "oclif": { + "bin": "mm", + "commands": "./dist/commands", + "dirname": "mm", + "topicSeparator": " " + }, "dependencies": { "@metamask/remote-feature-flag-controller": "^4.2.0", "@metamask/rpc-errors": "^7.0.2", @@ -65,11 +71,5 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" - }, - "oclif": { - "bin": "mm", - "commands": "./dist/commands", - "dirname": "mm", - "topicSeparator": " " } } From f3db6b78946618fc55a19ae7175c0df3fb195444 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:15:13 -0700 Subject: [PATCH 15/21] chore: Update teams.json and sync with CODEOWNERS --- .github/CODEOWNERS | 4 ++-- teams.json | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f45980f2e23..bbc8c2bedbe 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -118,8 +118,8 @@ /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform -/packages/wallet @MetaMask/core-platform @rekmarks @FrederikBolding -/packages/wallet-cli @MetaMask/core-platform @rekmarks @FrederikBolding +/packages/wallet @MetaMask/core-platform +/packages/wallet-cli @MetaMask/core-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/teams.json b/teams.json index b2648bc9e8d..4f9d0e96281 100644 --- a/teams.json +++ b/teams.json @@ -75,5 +75,7 @@ "metamask/remote-feature-flag-controller": "team-extension-platform,team-mobile-platform", "metamask/storage-service": "team-extension-platform,team-mobile-platform", "metamask/config-registry-controller": "team-networks", - "metamask/money-account-controller": "team-accounts-framework" + "metamask/money-account-controller": "team-accounts-framework", + "metamask/wallet": "team-core-platform", + "metamask/wallet-cli": "team-core-platform" } From ac488e5105d08250bd87f88f1ec7451824518e26 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:38:31 -0700 Subject: [PATCH 16/21] fix(wallet-cli): Await shutdown rm calls, use -32700 parse error, fix typedoc and JSDoc - Await rm calls in shutdown finally block via Promise.all and in error cleanup path so callers know cleanup is complete - Use rpcErrors.parse() (-32700) for JSON.parse failures per JSON-RPC spec - Update typedoc.json entry points after index.ts removal - Move CONNECTION_TIMEOUT_MS above JSDoc so doc attaches to the function Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wallet-cli/src/daemon/daemon-entry.ts | 8 ++++--- .../src/daemon/rpc-socket-server.test.ts | 2 +- .../src/daemon/rpc-socket-server.ts | 21 ++++++++++++------- packages/wallet-cli/typedoc.json | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index bcde91b89d2..c464028069a 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -87,7 +87,7 @@ async function main(): Promise { } catch { // Best-effort cleanup. } - rm(pidPath, { force: true }).catch(() => undefined); + await rm(pidPath, { force: true }).catch(() => undefined); throw error; } @@ -109,8 +109,10 @@ async function main(): Promise { await handle.close(); await wallet.destroy(); } finally { - rm(pidPath, { force: true }).catch(() => undefined); - rm(socketPath, { force: true }).catch(() => undefined); + await Promise.all([ + rm(pidPath, { force: true }).catch(() => undefined), + rm(socketPath, { force: true }).catch(() => undefined), + ]); } })(); } diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 7633477db36..61e68f207d0 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -494,7 +494,7 @@ describe('startRpcSocketServer', () => { await flushPromises(); - expect((getResponse(socket).error as { code: number }).code).toBe(-32603); + expect((getResponse(socket).error as { code: number }).code).toBe(-32700); }); it('uses null id when request has no id', async () => { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 12491b80a7b..2f4a59c9fe0 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -6,6 +6,8 @@ import type { Server } from 'node:net'; import type { RpcHandlerMap } from './types'; +const CONNECTION_TIMEOUT_MS = 30_000; + /** * Handle returned by {@link startRpcSocketServer}. */ @@ -28,8 +30,6 @@ export type RpcSocketServerHandle = { * @param options.onShutdown - Callback invoked when a `shutdown` RPC is received. * @returns A handle with a `close()` function for cleanup. */ -const CONNECTION_TIMEOUT_MS = 30_000; - export async function startRpcSocketServer({ socketPath, handlers, @@ -131,14 +131,21 @@ async function handleRequest( onShutdown?: () => Promise, ): Promise> { let id: unknown = null; + let request: { id?: unknown; method?: string; params?: unknown }; try { - const request = JSON.parse(line) as { - id?: unknown; - method?: string; - params?: unknown; + request = JSON.parse(line) as typeof request; + } catch { + return { + jsonrpc: '2.0', + id: null, + error: rpcErrors.parse({ message: 'Parse error' }).serialize(), }; - id = request.id ?? null; + } + + id = request.id ?? null; + + try { const { method } = request; if (typeof method !== 'string') { diff --git a/packages/wallet-cli/typedoc.json b/packages/wallet-cli/typedoc.json index c9da015dbf8..cb2d25b4bbb 100644 --- a/packages/wallet-cli/typedoc.json +++ b/packages/wallet-cli/typedoc.json @@ -1,5 +1,5 @@ { - "entryPoints": ["./src/index.ts"], + "entryPoints": ["./src/daemon/daemon-client.ts", "./src/daemon/types.ts"], "excludePrivate": true, "hideGenerator": true, "out": "docs", From 59518915b2d655313cea1bc9426e22531267e173 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:41:22 -0700 Subject: [PATCH 17/21] fix(wallet-cli): Validate action name is a string in call handler Reject params where the first element is not a string, preventing confusing downstream errors from messenger.call. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wallet-cli/src/daemon/daemon-entry.test.ts | 8 ++++++++ packages/wallet-cli/src/daemon/daemon-entry.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index 9920e2333c0..f85e42fba17 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -440,5 +440,13 @@ describe('daemon-entry', () => { 'Expected params to be an array with an action name', ); }); + + it('throws when action name is not a string', async () => { + const { callHandler } = await setupCallHandler(); + + await expect(callHandler([42])).rejects.toThrow( + 'Expected params to be an array with an action name', + ); + }); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index c464028069a..591f23c5d89 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -62,7 +62,7 @@ async function main(): Promise { // messenger surface over a local Unix socket. Access control is enforced // at the socket level (only local users can connect). call: async (params) => { - if (!Array.isArray(params) || params.length === 0) { + if (!Array.isArray(params) || typeof params[0] !== 'string') { throw new Error('Expected params to be an array with an action name'); } const [action, ...args] = params as [string, ...Json[]]; From 5e9f07c768d88c1889dd67c117100f3aa5290b49 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:16:29 -0700 Subject: [PATCH 18/21] fix(wallet-cli): Improve error handling, type safety, and cleanup - Log errors in catch blocks instead of silently swallowing them across daemon-entry, stop-daemon, and rpc-socket-server - Make shutdown cleanup independent: handle.close(), wallet.destroy(), and file removal each run in their own try/catch so one failure doesn't skip the others - Add default 30s timeout to sendCommand to prevent indefinite hangs - Use JsonRpcResponse from @metamask/utils as handleRequest return type - Add shared DaemonStatusInfo type for the getStatus RPC contract - Change RpcHandler to allow void return, document null params - Filter expected socket errors (EPIPE/ECONNRESET), log unexpected ones - Only suppress ENOENT in socket unlink, re-throw other errors - Clean up socket file (not just PID file) when stopDaemon succeeds - Add child.on('error') handler in daemon-spawn for spawn failures - Switch makeLogger from sync appendFileSync to async appendFile - Remove socketPath from DaemonSpawnConfig, derive from dataDir - Include error details in status command catch block Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wallet-cli/src/commands/daemon/start.ts | 4 +- .../wallet-cli/src/commands/daemon/status.ts | 9 +- .../wallet-cli/src/daemon/daemon-client.ts | 10 ++- .../src/daemon/daemon-entry.test.ts | 90 +++++++++++++++---- .../wallet-cli/src/daemon/daemon-entry.ts | 43 +++++---- .../src/daemon/daemon-spawn.test.ts | 39 +++++++- .../wallet-cli/src/daemon/daemon-spawn.ts | 7 +- .../src/daemon/rpc-socket-server.test.ts | 73 +++++++++++++-- .../src/daemon/rpc-socket-server.ts | 29 ++++-- .../wallet-cli/src/daemon/stop-daemon.test.ts | 2 + packages/wallet-cli/src/daemon/stop-daemon.ts | 17 ++-- packages/wallet-cli/src/daemon/types.ts | 13 ++- 12 files changed, 263 insertions(+), 73 deletions(-) diff --git a/packages/wallet-cli/src/commands/daemon/start.ts b/packages/wallet-cli/src/commands/daemon/start.ts index 61771e5658f..e0223a99ce9 100644 --- a/packages/wallet-cli/src/commands/daemon/start.ts +++ b/packages/wallet-cli/src/commands/daemon/start.ts @@ -37,17 +37,15 @@ export default class DaemonStart extends Command { const infuraProjectId = flags['infura-project-id']; const { password, srp } = flags; - const { socketPath } = getDaemonPaths(this.config.dataDir); - await ensureDaemon({ dataDir: this.config.dataDir, - socketPath, infuraProjectId, password, srp, packageRoot: this.config.root, }); + const { socketPath } = getDaemonPaths(this.config.dataDir); this.log(`Daemon running. Socket: ${socketPath}`); } } diff --git a/packages/wallet-cli/src/commands/daemon/status.ts b/packages/wallet-cli/src/commands/daemon/status.ts index 38f30f479a3..f8a2b618209 100644 --- a/packages/wallet-cli/src/commands/daemon/status.ts +++ b/packages/wallet-cli/src/commands/daemon/status.ts @@ -3,6 +3,7 @@ import { Command } from '@oclif/core'; import { pingDaemon, sendCommand } from '../../daemon/daemon-client'; import { getDaemonPaths } from '../../daemon/paths'; +import type { DaemonStatusInfo } from '../../daemon/types'; import { isProcessAlive, readPidFile } from '../../daemon/utils'; export default class DaemonStatus extends Command { @@ -36,8 +37,10 @@ export default class DaemonStatus extends Command { method: 'getStatus', timeoutMs: 5_000, }); - } catch { - this.log('Daemon socket is responsive but status request failed.'); + } catch (error) { + this.log( + `Daemon socket is responsive but status request failed: ${error instanceof Error ? error.message : String(error)}`, + ); return; } @@ -48,7 +51,7 @@ export default class DaemonStatus extends Command { return; } - const status = response.result as { pid: number; uptime: number }; + const status = response.result as DaemonStatusInfo; this.log( `Daemon is running. PID: ${status.pid}, Uptime: ${status.uptime}s`, ); diff --git a/packages/wallet-cli/src/daemon/daemon-client.ts b/packages/wallet-cli/src/daemon/daemon-client.ts index 68ff9d39d39..dd80f06753e 100644 --- a/packages/wallet-cli/src/daemon/daemon-client.ts +++ b/packages/wallet-cli/src/daemon/daemon-client.ts @@ -6,6 +6,8 @@ import type { Socket } from 'node:net'; import { readLine, writeLine } from './socket-line'; +const DEFAULT_TIMEOUT_MS = 30_000; + /** * Options for {@link sendCommand}. */ @@ -16,7 +18,7 @@ type SendCommandOptions = { method: string; /** Optional method parameters (object or positional array). */ params?: Record | unknown[] | undefined; - /** Read timeout in milliseconds (default: no timeout). */ + /** Response read timeout in milliseconds (default: 30 000). */ timeoutMs?: number | undefined; }; @@ -42,7 +44,7 @@ async function connectSocket(socketPath: string): Promise { * * Opens a connection, writes one JSON-RPC request line, reads one JSON-RPC * response line, then closes the connection. Retries once after a short delay - * if the connection is rejected. + * on transient connection errors (ECONNREFUSED, ECONNRESET). * * @param options - Command options. * @param options.socketPath - The Unix socket path. @@ -65,11 +67,13 @@ export async function sendCommand({ ...(params === undefined ? {} : { params }), }; + const effectiveTimeout = timeoutMs ?? DEFAULT_TIMEOUT_MS; + const attempt = async (): Promise => { const socket = await connectSocket(socketPath); try { await writeLine(socket, JSON.stringify(request)); - const responseLine = await readLine(socket, timeoutMs); + const responseLine = await readLine(socket, effectiveTimeout); const parsed: unknown = JSON.parse(responseLine); assertIsJsonRpcResponse(parsed); return parsed; diff --git a/packages/wallet-cli/src/daemon/daemon-entry.test.ts b/packages/wallet-cli/src/daemon/daemon-entry.test.ts index f85e42fba17..2e0e57193a4 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.test.ts @@ -1,5 +1,5 @@ -import { appendFileSync, mkdirSync } from 'node:fs'; -import { rm, writeFile } from 'node:fs/promises'; +import { mkdirSync } from 'node:fs'; +import { appendFile, rm, writeFile } from 'node:fs/promises'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; @@ -13,17 +13,13 @@ jest.mock('./rpc-socket-server'); jest.mock('./wallet-factory'); const mockMkdirSync = jest.mocked(mkdirSync); -const mockAppendFileSync = jest.mocked(appendFileSync); +const mockAppendFile = jest.mocked(appendFile); const mockWriteFile = jest.mocked(writeFile); const mockRm = jest.mocked(rm); const mockGetDaemonPaths = jest.mocked(getDaemonPaths); const mockStartRpcSocketServer = jest.mocked(startRpcSocketServer); const mockCreateWallet = jest.mocked(createWallet); -// The module under test calls main() at top level on import. -// We use jest.isolateModules to re-import it fresh in each test -// after setting up mocks and env vars. - const ORIGINAL_ENV = process.env; /** @@ -70,6 +66,7 @@ describe('daemon-entry', () => { }); mockWriteFile.mockResolvedValue(undefined); mockRm.mockResolvedValue(undefined); + mockAppendFile.mockResolvedValue(undefined); }); afterEach(() => { @@ -83,6 +80,9 @@ describe('daemon-entry', () => { * Returns after main() settles. */ async function importDaemonEntry(): Promise { + // The module under test calls main() at top level on import. + // We use jest.isolateModules to re-import it fresh in each test + // after setting up mocks and env vars. await jest.isolateModulesAsync(async () => { await import('./daemon-entry'); // Flush microtasks so main()'s .catch() handler settles @@ -229,13 +229,30 @@ describe('daemon-entry', () => { await importDaemonEntry(); - // makeLogger writes via appendFileSync to the log path - expect(mockAppendFileSync).toHaveBeenCalledWith( + // makeLogger writes via appendFile to the log path + expect(mockAppendFile).toHaveBeenCalledWith( '/tmp/daemon.log', expect.stringContaining('Starting daemon...'), ); }); + it('writes to stderr when appendFile fails in makeLogger', async () => { + mockAppendFile.mockRejectedValue(new Error('disk full')); + mockCreateWallet.mockResolvedValue(createMockWallet()); + mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); + + await importDaemonEntry(); + + // Flush the appendFile rejection handler + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => process.nextTick(resolve)); + } + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('log write failed'), + ); + }); + it('registers SIGTERM and SIGINT handlers', async () => { mockCreateWallet.mockResolvedValue(createMockWallet()); mockStartRpcSocketServer.mockResolvedValue(createMockHandle()); @@ -249,35 +266,32 @@ describe('daemon-entry', () => { expect(registeredEvents).toContain('SIGINT'); }); - it('sIGTERM handler calls shutdown and sets exitCode on failure', async () => { + it('triggers shutdown when SIGTERM handler is called', async () => { const wallet = createMockWallet(); mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); - (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); const onSpy = jest.spyOn(process, 'on'); await importDaemonEntry(); - // Find the SIGTERM handler and invoke it const sigTermCall = onSpy.mock.calls.find(([event]) => event === 'SIGTERM'); const sigTermHandler = sigTermCall?.[1] as () => void; sigTermHandler(); - // Flush promise chain for (let i = 0; i < 10; i++) { await new Promise((resolve) => process.nextTick(resolve)); } - expect(process.exitCode).toBe(1); + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); }); - it('sIGINT handler calls shutdown and sets exitCode on failure', async () => { + it('triggers shutdown when SIGINT handler is called', async () => { const wallet = createMockWallet(); mockCreateWallet.mockResolvedValue(wallet); const handle = createMockHandle(); - (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); mockStartRpcSocketServer.mockResolvedValue(handle); const onSpy = jest.spyOn(process, 'on'); @@ -292,7 +306,49 @@ describe('daemon-entry', () => { await new Promise((resolve) => process.nextTick(resolve)); } - expect(process.exitCode).toBe(1); + expect(handle.close).toHaveBeenCalled(); + expect(wallet.destroy).toHaveBeenCalled(); + }); + + it('shutdown still calls wallet.destroy when handle.close fails', async () => { + const wallet = createMockWallet(); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + (handle.close as jest.Mock).mockRejectedValue(new Error('close failed')); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(wallet.destroy).toHaveBeenCalled(); + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('handle.close() failed'), + ); + }); + + it('shutdown logs wallet.destroy failure', async () => { + const wallet = createMockWallet(); + (wallet.destroy as jest.Mock).mockRejectedValue( + new Error('destroy failed'), + ); + mockCreateWallet.mockResolvedValue(wallet); + const handle = createMockHandle(); + mockStartRpcSocketServer.mockResolvedValue(handle); + + await importDaemonEntry(); + + const callArgs = mockStartRpcSocketServer.mock.calls[0][0]; + const onShutdown = callArgs.onShutdown as () => Promise; + await onShutdown(); + + expect(mockAppendFile).toHaveBeenCalledWith( + '/tmp/daemon.log', + expect.stringContaining('wallet.destroy() failed'), + ); }); it('handles rm rejection during shutdown cleanup gracefully', async () => { diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 591f23c5d89..97d5c4eac3b 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -1,11 +1,11 @@ import type { Json } from '@metamask/utils'; -import { appendFileSync, mkdirSync } from 'node:fs'; -import { rm, writeFile } from 'node:fs/promises'; +import { mkdirSync } from 'node:fs'; +import { appendFile, rm, writeFile } from 'node:fs/promises'; import { getDaemonPaths } from './paths'; import { startRpcSocketServer } from './rpc-socket-server'; import type { RpcSocketServerHandle } from './rpc-socket-server'; -import type { RpcHandlerMap } from './types'; +import type { DaemonStatusInfo, RpcHandlerMap } from './types'; import { createWallet } from './wallet-factory'; const startTime = Date.now(); @@ -54,7 +54,7 @@ async function main(): Promise { const wallet = await createWallet({ infuraProjectId, password, srp }); const handlers: RpcHandlerMap = { - getStatus: async () => ({ + getStatus: async (): Promise => ({ pid: process.pid, uptime: Math.floor((Date.now() - startTime) / 1000), }), @@ -84,10 +84,12 @@ async function main(): Promise { } catch (error) { try { await wallet.destroy(); - } catch { - // Best-effort cleanup. + } catch (destroyError) { + log(`wallet.destroy() failed during cleanup: ${String(destroyError)}`); } - await rm(pidPath, { force: true }).catch(() => undefined); + await rm(pidPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove PID file during cleanup: ${String(rmError)}`); + }); throw error; } @@ -107,23 +109,32 @@ async function main(): Promise { shutdownPromise = (async (): Promise => { try { await handle.close(); + } catch (closeError) { + log(`handle.close() failed: ${String(closeError)}`); + } + try { await wallet.destroy(); - } finally { - await Promise.all([ - rm(pidPath, { force: true }).catch(() => undefined), - rm(socketPath, { force: true }).catch(() => undefined), - ]); + } catch (destroyError) { + log(`wallet.destroy() failed: ${String(destroyError)}`); } + await Promise.all([ + rm(pidPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove PID file: ${String(rmError)}`); + }), + rm(socketPath, { force: true }).catch((rmError: unknown) => { + log(`Failed to remove socket file: ${String(rmError)}`); + }), + ]); })(); } return shutdownPromise; } process.on('SIGTERM', () => { - shutdown('SIGTERM').catch(() => (process.exitCode = 1)); + shutdown('SIGTERM').catch(() => undefined); }); process.on('SIGINT', () => { - shutdown('SIGINT').catch(() => (process.exitCode = 1)); + shutdown('SIGINT').catch(() => undefined); }); } @@ -136,6 +147,8 @@ async function main(): Promise { function makeLogger(logPath: string): (message: string) => void { return (message: string): void => { const line = `[${new Date().toISOString()}] ${message}\n`; - appendFileSync(logPath, line); + appendFile(logPath, line).catch((error: unknown) => { + process.stderr.write(`[log write failed: ${String(error)}] ${message}\n`); + }); }; } diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts index a8d74d96124..2e42e7ae065 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.test.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.test.ts @@ -3,19 +3,21 @@ import { existsSync } from 'node:fs'; import { pingDaemon } from './daemon-client'; import { ensureDaemon } from './daemon-spawn'; +import { getDaemonPaths } from './paths'; import type { DaemonSpawnConfig } from './types'; jest.mock('node:child_process'); jest.mock('node:fs'); jest.mock('./daemon-client'); +jest.mock('./paths'); const mockSpawn = jest.mocked(spawn); const mockExistsSync = jest.mocked(existsSync); const mockPingDaemon = jest.mocked(pingDaemon); +const mockGetDaemonPaths = jest.mocked(getDaemonPaths); const CONFIG: DaemonSpawnConfig = { dataDir: '/tmp/data', - socketPath: '/tmp/test.sock', infuraProjectId: 'test-key', password: 'test-pass', srp: 'test test test test test test test test test test test ball', @@ -25,8 +27,14 @@ const CONFIG: DaemonSpawnConfig = { describe('ensureDaemon', () => { beforeEach(() => { jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + mockGetDaemonPaths.mockReturnValue({ + socketPath: '/tmp/test.sock', + pidPath: '/tmp/test.pid', + logPath: '/tmp/test.log', + }); mockSpawn.mockReturnValue({ unref: jest.fn(), + on: jest.fn(), } as never); }); @@ -51,7 +59,6 @@ describe('ensureDaemon', () => { stdio: 'ignore', env: expect.objectContaining({ MM_DAEMON_DATA_DIR: '/tmp/data', - MM_DAEMON_SOCKET_PATH: '/tmp/test.sock', INFURA_PROJECT_ID: 'test-key', MM_WALLET_PASSWORD: 'test-pass', MM_WALLET_SRP: @@ -118,14 +125,38 @@ describe('ensureDaemon', () => { jest.useRealTimers(); }); - it('calls unref on spawned child', async () => { + it('calls unref on spawned child and registers error handler', async () => { const unref = jest.fn(); + const on = jest.fn(); mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockExistsSync.mockReturnValue(true); - mockSpawn.mockReturnValue({ unref } as never); + mockSpawn.mockReturnValue({ unref, on } as never); await ensureDaemon(CONFIG); expect(unref).toHaveBeenCalled(); + expect(on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('writes spawn errors to stderr', async () => { + const unref = jest.fn(); + let errorHandler: ((error: Error) => void) | undefined; + const on = jest.fn( + (event: string, handler: (error: Error) => void): void => { + if (event === 'error') { + errorHandler = handler; + } + }, + ); + mockPingDaemon.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockExistsSync.mockReturnValue(true); + mockSpawn.mockReturnValue({ unref, on } as never); + + await ensureDaemon(CONFIG); + errorHandler?.(new Error('spawn ENOENT')); + + expect(process.stderr.write).toHaveBeenCalledWith( + expect.stringContaining('Failed to spawn daemon process'), + ); }); }); diff --git a/packages/wallet-cli/src/daemon/daemon-spawn.ts b/packages/wallet-cli/src/daemon/daemon-spawn.ts index 75d1611b8a9..5a63815d5d7 100644 --- a/packages/wallet-cli/src/daemon/daemon-spawn.ts +++ b/packages/wallet-cli/src/daemon/daemon-spawn.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { pingDaemon } from './daemon-client'; +import { getDaemonPaths } from './paths'; import type { DaemonSpawnConfig } from './types'; const POLL_INTERVAL_MS = 100; @@ -15,7 +16,7 @@ const MAX_POLLS = 300; // 30 seconds * @param config - Spawn configuration. */ export async function ensureDaemon(config: DaemonSpawnConfig): Promise { - const { socketPath } = config; + const { socketPath } = getDaemonPaths(config.dataDir); if (await pingDaemon(socketPath)) { return; } @@ -30,12 +31,14 @@ export async function ensureDaemon(config: DaemonSpawnConfig): Promise { env: { ...process.env, MM_DAEMON_DATA_DIR: config.dataDir, - MM_DAEMON_SOCKET_PATH: socketPath, INFURA_PROJECT_ID: config.infuraProjectId, MM_WALLET_PASSWORD: config.password, MM_WALLET_SRP: config.srp, }, }); + child.on('error', (error) => { + process.stderr.write(`Failed to spawn daemon process: ${String(error)}\n`); + }); child.unref(); for (let i = 0; i < MAX_POLLS; i++) { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts index 61e68f207d0..29bc128640b 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.test.ts @@ -108,8 +108,10 @@ describe('startRpcSocketServer', () => { expect(mockUnlink).toHaveBeenCalledWith('/tmp/test.sock'); }); - it('ignores unlink errors for missing files', async () => { - mockUnlink.mockRejectedValue(new Error('ENOENT')); + it('ignores ENOENT unlink errors for missing files', async () => { + mockUnlink.mockRejectedValue( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); createMockServer(); const handle = await startRpcSocketServer({ @@ -119,6 +121,20 @@ describe('startRpcSocketServer', () => { expect(handle).toBeDefined(); }); + it('propagates non-ENOENT unlink errors', async () => { + mockUnlink.mockRejectedValue( + Object.assign(new Error('EACCES'), { code: 'EACCES' }), + ); + createMockServer(); + + await expect( + startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }), + ).rejects.toThrow('EACCES'); + }); + it('returns a handle with close()', async () => { const { server } = createMockServer(); const handle = await startRpcSocketServer({ @@ -345,8 +361,11 @@ describe('startRpcSocketServer', () => { jest.useRealTimers(); }); - it('handles onShutdown rejection gracefully', async () => { + it('handles onShutdown rejection and logs to stderr', async () => { jest.useFakeTimers(); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); const { simulateConnection } = createMockServer(); const onShutdown = jest.fn().mockRejectedValue(new Error('shutdown err')); @@ -365,7 +384,11 @@ describe('startRpcSocketServer', () => { expect(getResponse(socket).result).toStrictEqual({ status: 'shutting down', }); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('onShutdown callback failed'), + ); jest.useRealTimers(); + stderrSpy.mockRestore(); }); it('responds to shutdown even without onShutdown callback', async () => { @@ -441,7 +464,10 @@ describe('startRpcSocketServer', () => { expect(getResponse(socket).result).toBe('ok'); }); - it('ignores socket errors', async () => { + it('silently ignores EPIPE and ECONNRESET socket errors', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); const { simulateConnection } = createMockServer(); await startRpcSocketServer({ socketPath: '/tmp/test.sock', @@ -451,10 +477,41 @@ describe('startRpcSocketServer', () => { const socket = createMockSocket(); simulateConnection(socket); - // Should not throw - expect(() => - socket.emit('error', new Error('broken pipe')), - ).not.toThrow(); + socket.emit( + 'error', + Object.assign(new Error('broken pipe'), { code: 'EPIPE' }), + ); + socket.emit( + 'error', + Object.assign(new Error('reset'), { code: 'ECONNRESET' }), + ); + + expect(stderrSpy).not.toHaveBeenCalled(); + stderrSpy.mockRestore(); + }); + + it('logs unexpected socket errors to stderr', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const { simulateConnection } = createMockServer(); + await startRpcSocketServer({ + socketPath: '/tmp/test.sock', + handlers: {}, + }); + + const socket = createMockSocket(); + simulateConnection(socket); + + socket.emit( + 'error', + Object.assign(new Error('unexpected'), { code: 'ENOMEM' }), + ); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Unexpected socket error'), + ); + stderrSpy.mockRestore(); }); it('sends internal error when response serialization fails', async () => { diff --git a/packages/wallet-cli/src/daemon/rpc-socket-server.ts b/packages/wallet-cli/src/daemon/rpc-socket-server.ts index 2f4a59c9fe0..5e00290e745 100644 --- a/packages/wallet-cli/src/daemon/rpc-socket-server.ts +++ b/packages/wallet-cli/src/daemon/rpc-socket-server.ts @@ -1,10 +1,12 @@ import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcResponse } from '@metamask/utils'; import { hasProperty } from '@metamask/utils'; import { unlink } from 'node:fs/promises'; import { createServer } from 'node:net'; import type { Server } from 'node:net'; import type { RpcHandlerMap } from './types'; +import { isErrorWithCode } from './utils'; const CONNECTION_TIMEOUT_MS = 30_000; @@ -95,8 +97,12 @@ export async function startRpcSocketServer({ }; socket.on('data', onData); - socket.on('error', () => { - // Ignore client socket errors (e.g. broken pipe from probe connections). + socket.on('error', (socketError: NodeJS.ErrnoException) => { + const { code } = socketError; + if (code === 'EPIPE' || code === 'ECONNRESET') { + return; // Expected during probe/disconnect. + } + process.stderr.write(`Unexpected socket error: ${String(socketError)}\n`); }); }); @@ -129,8 +135,9 @@ async function handleRequest( handlers: RpcHandlerMap, line: string, onShutdown?: () => Promise, -): Promise> { - let id: unknown = null; +): Promise { + type JsonRpcId = string | number | null; + let id: JsonRpcId = null; let request: { id?: unknown; method?: string; params?: unknown }; try { @@ -143,7 +150,7 @@ async function handleRequest( }; } - id = request.id ?? null; + id = (request.id ?? null) as JsonRpcId; try { const { method } = request; @@ -162,8 +169,10 @@ async function handleRequest( if (method === 'shutdown') { if (onShutdown) { setTimeout(() => { - onShutdown().catch(() => { - // Best-effort shutdown. + onShutdown().catch((error: unknown) => { + process.stderr.write( + `onShutdown callback failed: ${String(error)}\n`, + ); }); }, 0); } @@ -225,8 +234,10 @@ function isRpcError( async function listen(server: Server, socketPath: string): Promise { try { await unlink(socketPath); - } catch { - // Ignore — file may not exist. + } catch (error) { + if (!isErrorWithCode(error, 'ENOENT')) { + throw error; + } } return new Promise((resolve, reject) => { diff --git a/packages/wallet-cli/src/daemon/stop-daemon.test.ts b/packages/wallet-cli/src/daemon/stop-daemon.test.ts index d03fd6cc115..48e276eeed7 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.test.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.test.ts @@ -64,6 +64,8 @@ describe('stopDaemon', () => { }); expect(log).toHaveBeenCalledWith('Stopping daemon...'); expect(log).toHaveBeenCalledWith('Daemon stopped.'); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.pid', { force: true }); + expect(mockRm).toHaveBeenCalledWith('/tmp/test.sock', { force: true }); }); it('falls through to SIGTERM when graceful shutdown times out', async () => { diff --git a/packages/wallet-cli/src/daemon/stop-daemon.ts b/packages/wallet-cli/src/daemon/stop-daemon.ts index 24a47f158cf..dc5f0c27901 100644 --- a/packages/wallet-cli/src/daemon/stop-daemon.ts +++ b/packages/wallet-cli/src/daemon/stop-daemon.ts @@ -36,8 +36,8 @@ export async function stopDaemon( if (socketResponsive) { try { await sendCommand({ socketPath, method: 'shutdown' }); - } catch { - // Socket became unresponsive. + } catch (error) { + log?.(`Graceful shutdown request failed: ${String(error)}`); } stopped = await waitFor(async () => !(await pingDaemon(socketPath)), 5_000); } @@ -50,8 +50,8 @@ export async function stopDaemon( } else { stopped = true; // Process already gone (ESRCH). } - } catch { - // Permission error — fall through to next strategy. + } catch (error) { + log?.(`SIGTERM failed: ${String(error)}`); } } @@ -63,13 +63,16 @@ export async function stopDaemon( } else { stopped = true; // Process already gone (ESRCH). } - } catch { - // Permission error — cannot kill process. + } catch (error) { + log?.(`SIGKILL failed: ${String(error)}`); } } if (stopped) { - await rm(pidPath, { force: true }); + await Promise.all([ + rm(pidPath, { force: true }), + rm(socketPath, { force: true }), + ]); log?.('Daemon stopped.'); } diff --git a/packages/wallet-cli/src/daemon/types.ts b/packages/wallet-cli/src/daemon/types.ts index 714f6e81a93..4d2793cb23c 100644 --- a/packages/wallet-cli/src/daemon/types.ts +++ b/packages/wallet-cli/src/daemon/types.ts @@ -2,8 +2,10 @@ import type { Json } from '@metamask/utils'; /** * A function that handles a JSON-RPC method call. + * + * The `params` argument will be `null` if the client did not provide params. */ -export type RpcHandler = (params: Json) => Promise; +export type RpcHandler = (params: Json) => Promise; /** * A map of RPC method names to their handler functions. @@ -19,12 +21,19 @@ export type DaemonPaths = { logPath: string; }; +/** + * Status information returned by the daemon's `getStatus` RPC method. + */ +export type DaemonStatusInfo = { + pid: number; + uptime: number; +}; + /** * Configuration passed to the daemon spawner. */ export type DaemonSpawnConfig = { dataDir: string; - socketPath: string; infuraProjectId: string; password: string; srp: string; From ed7558091146370450ff9265cf35ec5a0be0b238 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:20:37 -0700 Subject: [PATCH 19/21] feat(wallet-cli): Add confirmation prompt to daemon purge command Replace the required --force flag with an interactive y/N confirmation prompt using @inquirer/confirm. The --force flag now skips the prompt instead of being mandatory. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/wallet-cli/package.json | 1 + .../wallet-cli/src/commands/daemon/purge.ts | 22 +++++-- yarn.lock | 62 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 62674f030cb..ed62fb7439f 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -47,6 +47,7 @@ "topicSeparator": " " }, "dependencies": { + "@inquirer/confirm": "^6.0.11", "@metamask/remote-feature-flag-controller": "^4.2.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/utils": "^11.9.0", diff --git a/packages/wallet-cli/src/commands/daemon/purge.ts b/packages/wallet-cli/src/commands/daemon/purge.ts index cb4e969fff0..2a09db3ee9c 100644 --- a/packages/wallet-cli/src/commands/daemon/purge.ts +++ b/packages/wallet-cli/src/commands/daemon/purge.ts @@ -8,18 +8,32 @@ 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 --force']; + static override examples = [ + '<%= config.bin %> daemon purge', + '<%= config.bin %> daemon purge --force', + ]; static override flags = { force: Flags.boolean({ char: 'f', - description: 'Required to confirm purge', - required: true, + description: 'Skip confirmation prompt', }), }; public async run(): Promise { - await this.parse(DaemonPurge); + const { flags } = await this.parse(DaemonPurge); + + if (!flags.force) { + const { default: confirm } = await import('@inquirer/confirm'); + const confirmed = await confirm({ + message: 'This will stop the daemon and delete all state. Continue?', + default: false, + }); + if (!confirmed) { + this.log('Aborted.'); + return; + } + } const { socketPath, pidPath } = getDaemonPaths(this.config.dataDir); diff --git a/yarn.lock b/yarn.lock index 1ef65b7ace0..81c4c760e17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1799,6 +1799,13 @@ __metadata: languageName: node linkType: hard +"@inquirer/ansi@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/ansi@npm:2.0.5" + checksum: 10/482f8a606885ee0377a60eb5e9b303ae75fcfb2c6250819be348047c89e4e01a25feef369d3646dec7ba17e38cd5cc08271db6db21c401be315b3ada749e6b53 + languageName: node + linkType: hard + "@inquirer/checkbox@npm:^5.0.5": version: 5.0.7 resolution: "@inquirer/checkbox@npm:5.0.7" @@ -1827,6 +1834,21 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^6.0.11": + version: 6.0.11 + resolution: "@inquirer/confirm@npm:6.0.11" + dependencies: + "@inquirer/core": "npm:^11.1.8" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/f51ead4a6a68ac585257e66bbe8196a6b7aec1956b12038827a2d03a509b9db8e0ece97d4b92033259090de33d9aefd0cff288cd4dce6f472d927ef8fe9302f5 + languageName: node + linkType: hard + "@inquirer/confirm@npm:^6.0.5": version: 6.0.7 resolution: "@inquirer/confirm@npm:6.0.7" @@ -1879,6 +1901,26 @@ __metadata: languageName: node linkType: hard +"@inquirer/core@npm:^11.1.8": + version: 11.1.8 + resolution: "@inquirer/core@npm:11.1.8" + dependencies: + "@inquirer/ansi": "npm:^2.0.5" + "@inquirer/figures": "npm:^2.0.5" + "@inquirer/type": "npm:^4.0.5" + cli-width: "npm:^4.1.0" + fast-wrap-ansi: "npm:^0.2.0" + mute-stream: "npm:^3.0.0" + signal-exit: "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/e034f637ea9c12c2aaf8f5b128611f9d72976b50cf387f1207e0459342924c64f2de7e675e2b86616c44daf05700c4764f83e7ca1417aa41ed3d29d458062218 + languageName: node + linkType: hard + "@inquirer/editor@npm:^5.0.5": version: 5.0.7 resolution: "@inquirer/editor@npm:5.0.7" @@ -1932,6 +1974,13 @@ __metadata: languageName: node linkType: hard +"@inquirer/figures@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/figures@npm:2.0.5" + checksum: 10/e4d09c11a75206578abcfd8fc69b0f54cff7a853826696df5b3a45ed24ebc5c82e8998f1e9fa42119de848e6a0a526a6ac476053800413637bf6d21c2116cc60 + languageName: node + linkType: hard + "@inquirer/input@npm:^0.0.15-alpha.0": version: 0.0.15-alpha.0 resolution: "@inquirer/input@npm:0.0.15-alpha.0" @@ -2071,6 +2120,18 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^4.0.5": + version: 4.0.5 + resolution: "@inquirer/type@npm:4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10/83d15e11cc0586373070e8c262f69b1d1e4a6c72f58b3afb3d163479309f5a9bb584320eec2d85474506fb845a114e2c50010758fcf3af56c93293d579f76333 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -5681,6 +5742,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/wallet-cli@workspace:packages/wallet-cli" dependencies: + "@inquirer/confirm": "npm:^6.0.11" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/remote-feature-flag-controller": "npm:^4.2.0" "@metamask/rpc-errors": "npm:^7.0.2" From 7ded3ef48724c19021813f219c4ac44fd92e1b6f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:26:43 -0700 Subject: [PATCH 20/21] chore: lint dependencies --- yarn.lock | 71 ++++--------------------------------------------------- 1 file changed, 5 insertions(+), 66 deletions(-) diff --git a/yarn.lock b/yarn.lock index 81c4c760e17..de9f28b10d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1792,14 +1792,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/ansi@npm:^2.0.3": - version: 2.0.3 - resolution: "@inquirer/ansi@npm:2.0.3" - checksum: 10/846bf48acc4a89e62c2be49af74c014311e5a575a46875fe3066c28ac241671132fb16518e859bd63db6a2be326a6a7160c9a79f60013b1a2c6a1c1d620a98ac - languageName: node - linkType: hard - -"@inquirer/ansi@npm:^2.0.5": +"@inquirer/ansi@npm:^2.0.3, @inquirer/ansi@npm:^2.0.5": version: 2.0.5 resolution: "@inquirer/ansi@npm:2.0.5" checksum: 10/482f8a606885ee0377a60eb5e9b303ae75fcfb2c6250819be348047c89e4e01a25feef369d3646dec7ba17e38cd5cc08271db6db21c401be315b3ada749e6b53 @@ -1834,7 +1827,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:^6.0.11": +"@inquirer/confirm@npm:^6.0.11, @inquirer/confirm@npm:^6.0.5": version: 6.0.11 resolution: "@inquirer/confirm@npm:6.0.11" dependencies: @@ -1849,21 +1842,6 @@ __metadata: languageName: node linkType: hard -"@inquirer/confirm@npm:^6.0.5": - version: 6.0.7 - resolution: "@inquirer/confirm@npm:6.0.7" - dependencies: - "@inquirer/core": "npm:^11.1.4" - "@inquirer/type": "npm:^4.0.3" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/c7da13527dd46f09515f68f1a809791eb8fc349422a39d9fd4c4254082edff3f8c9ffda4c86d10f1b80ebdbed34d2bc2c86c2275096be3d4559a35a88955da41 - languageName: node - linkType: hard - "@inquirer/core@npm:^0.0.15-alpha.0": version: 0.0.15-alpha.0 resolution: "@inquirer/core@npm:0.0.15-alpha.0" @@ -1881,27 +1859,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/core@npm:^11.1.4": - version: 11.1.4 - resolution: "@inquirer/core@npm:11.1.4" - dependencies: - "@inquirer/ansi": "npm:^2.0.3" - "@inquirer/figures": "npm:^2.0.3" - "@inquirer/type": "npm:^4.0.3" - cli-width: "npm:^4.1.0" - fast-wrap-ansi: "npm:^0.2.0" - mute-stream: "npm:^3.0.0" - signal-exit: "npm:^4.1.0" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/e022de7f3b9b65b7ae98b5316b205ec59c84294936fd63f57b37673f2d334dd8b762c50e8d3edf59caac72d6bed86e533fa88e87f3e271a3275c0a1f6acf0271 - languageName: node - linkType: hard - -"@inquirer/core@npm:^11.1.8": +"@inquirer/core@npm:^11.1.4, @inquirer/core@npm:^11.1.8": version: 11.1.8 resolution: "@inquirer/core@npm:11.1.8" dependencies: @@ -1967,14 +1925,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/figures@npm:^2.0.3": - version: 2.0.3 - resolution: "@inquirer/figures@npm:2.0.3" - checksum: 10/d1496081e28e8fb5f50790fdbf7fb5f437933de557092a3eaac7f290190f9597ff9d75693bbb6d94e3da71b4dae567f2e88d3c54557b280b7b832219ff26e072 - languageName: node - linkType: hard - -"@inquirer/figures@npm:^2.0.5": +"@inquirer/figures@npm:^2.0.3, @inquirer/figures@npm:^2.0.5": version: 2.0.5 resolution: "@inquirer/figures@npm:2.0.5" checksum: 10/e4d09c11a75206578abcfd8fc69b0f54cff7a853826696df5b3a45ed24ebc5c82e8998f1e9fa42119de848e6a0a526a6ac476053800413637bf6d21c2116cc60 @@ -2108,19 +2059,7 @@ __metadata: languageName: node linkType: hard -"@inquirer/type@npm:^4.0.3": - version: 4.0.3 - resolution: "@inquirer/type@npm:4.0.3" - peerDependencies: - "@types/node": ">=18" - peerDependenciesMeta: - "@types/node": - optional: true - checksum: 10/93166fc35dbb597067341c125090a51b1c8d0d57308ec14ad601727513d0aefa33643cff28aa52e7e6796a866bb2f60b4e49820774970110fe8454a276ac7c4c - languageName: node - linkType: hard - -"@inquirer/type@npm:^4.0.5": +"@inquirer/type@npm:^4.0.3, @inquirer/type@npm:^4.0.5": version: 4.0.5 resolution: "@inquirer/type@npm:4.0.5" peerDependencies: From f69d1ccecbe6ffb91e7ca2937d712cd60725b8f9 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:45:56 -0700 Subject: [PATCH 21/21] chore: Fix wallet-cli test coverage --- packages/wallet-cli/src/daemon/daemon-entry.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/wallet-cli/src/daemon/daemon-entry.ts b/packages/wallet-cli/src/daemon/daemon-entry.ts index 97d5c4eac3b..2ae410462ac 100644 --- a/packages/wallet-cli/src/daemon/daemon-entry.ts +++ b/packages/wallet-cli/src/daemon/daemon-entry.ts @@ -131,9 +131,11 @@ async function main(): Promise { } process.on('SIGTERM', () => { + /* istanbul ignore next */ shutdown('SIGTERM').catch(() => undefined); }); process.on('SIGINT', () => { + /* istanbul ignore next */ shutdown('SIGINT').catch(() => undefined); }); }