@deeeed/metamask-harness 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 - 2026-07-04
4
+
5
+ ### Fixed
6
+ - **IMP-21: FORCE_COLOR-safe pod install** — the mobile pod-triggering leaves (`yarn-setup.sh` running `yarn setup`, and `open-device.sh` running the native `yarn start:ios|android` build) now run with `FORCE_COLOR=0` / `NO_COLOR=1`. VisionCamera's podspec probes `node --print require.resolve('react-native-worklets-core')` and treats any output other than the exact string `undefined` as "found"; an inherited `FORCE_COLOR` made node emit a colorized `undefined`, enabling FrameProcessors and hard-failing on the missing worklets pod. Setup/native builds are now reliable from a `FORCE_COLOR` shell.
7
+ - **IMP-22: `--watcher-port` beats `.js.env WATCHER_PORT`** — `verify.sh` port resolution now honors an explicit `WATCHER_PORT` in the process env (which carries `mm-harness launch --watcher-port N`) over the target's `.js.env`. Precedence is flag > process env > `.js.env` > `8081` default, so a run can be pointed at an alternate Metro/CDP port without editing a shared slot's `.js.env`.
8
+
9
+ ## 0.3.1 - 2026-07-03
10
+
11
+ ### Fixed
12
+ - **Customization compose-by-ref**: recipes with `{action:"call",ref:"<lib>.<flow>"}` + `--library <lib>=<dir>` now pass pre-run schema validation AND execute at runtime. `validateRecipeAdapterAware` threads `externalFlowIds` into both `validateRecipeDocument` and `validateRecipeWithManifest`; library resolution also runs at `run` time.
13
+ - **IMP-14: stale metro.log guard** — if `metro.log` is older than the install markers (`node_modules/.yarn-state.yml`, `.yarn/install-state.gz`), it is treated as absent so a stale log cannot drive a false native-module-mismatch decision at launch.
14
+ - **IMP-15: stale overlay shim auto-repair (two modes)** — `ensureOverlay` detects (1) a `.runner-source` pointing to a nonexistent runner dir, and (2) a `.runner-source` whose dir exists but whose `bin/mm-harness` was removed. Both re-install automatically before launch.
15
+ - **IMP-17: deps-staleness fallback (two-layer)** — (1) mtime comparison when no baseline exists: manifest files newer than install markers → `install`; (2) required-dep absence: a top-level `package.json` dep absent from `node_modules` is caught at pre-flight as `deps-partial` → `install`, before Metro discovers it during a long bundle run.
16
+ - **IMP-18: iOS sim scheme-approval pre-write** — `open-device.sh` writes `com.apple.launchservices.schemeapproval` via `xcrun simctl spawn … defaults write` before the first `openurl`, eliminating the "Open in \<App\>?" dialog hang. Scheme derives from `DEV_CLIENT_SCHEME` (overridable via `IOS_DEV_CLIENT_SCHEME`).
17
+
3
18
  ## 0.3.0 - 2026-07-03
4
19
 
5
20
  ### Changed
@@ -13,8 +28,15 @@
13
28
 
14
29
  ## Unreleased
15
30
 
31
+ ### Added
32
+
33
+ - **`mm-harness update`**: checks the npm registry `dist-tags.latest` for `@deeeed/metamask-harness` and, when newer, runs `npm i -g @deeeed/metamask-harness@latest` and prints `old → new`; already-current exits 0. `--check` reports only (exit 0 up-to-date, exit 1 update available); `--json` carries `{ current, latest, updateAvailable }`. Every failure path (no npm, registry unreachable, permission-denied global dir) exits 3 with the exact escape.
34
+ - **Passive update nudge**: on invocation (throttled to once/day via a cache file, bounded fetch, skipped in CI and when `MM_HARNESS_NO_UPDATE_CHECK=1`), a single `stderr` line announces a newer published version — never on stdout, never blocking, never auto-updating.
35
+ - **`MM_HARNESS_BIN` dev/prod switch**: the entrypoint honors `MM_HARNESS_BIN` as the single override point — when set, the invocation is handed to that dev checkout's `bin/mm-harness`; unset resolves the installed/global bin (prod). A self-path guard prevents an exec loop, and a non-executable value teaches its fix. Documented in `--help` and README.
36
+
16
37
  ### Changed
17
38
 
39
+ - **Teaching errors are type-enforced**: `usageOut` now requires a `userAction` escape argument, so a teaching error without a reachable next step no longer compiles. The repo-type detection failure teaches BOTH escapes (`cd into a checkout / pass --target <path>` and `--adapter <mobile|extension|core>`) consistently across `doctor`, `launch`, `verify`, and the overlay path.
18
40
  - **Repository reorganized into `src/`, `adapters/`, `library/` domains**: all TypeScript now lives under `src/` (shared CLI/core plus typed per-adapter logic in `src/adapters/<adapter>/`); everything that executes against or is injected into the app runtime lives under `adapters/` (`adapters/<adapter>/` per-app glue, `adapters/shared/` cross-adapter OS-glue, `adapters/porcelain/` composed entrypoints, `adapters/*/overlay/` the only injected payload); `library/` holds composable vocabulary only. The `runner/` and `orchestration/` trees no longer exist. The surface doctor moved to `scripts/adapter-surface-doctor.mjs` with its index at `adapters/manifest.json`. Placement rules (R1–R11) are documented in `AGENTS.md`. This is a path-only reorganization; runtime behavior is unchanged.
19
41
 
20
42
  ## 0.2.0 - 2026-07-03
package/README.md CHANGED
@@ -84,8 +84,17 @@ mm-harness launch ios # launch (mobile: ios | android)
84
84
  mm-harness logs # tail Metro/webpack + app logs
85
85
  mm-harness debug # open the debug console
86
86
  mm-harness fixtures sync # sync fixtures; `fixtures set` seeds the wallet
87
+
88
+ # Keep the install current
89
+ mm-harness update # upgrade to the published latest (prints old → new)
90
+ mm-harness update --check --json # report only — { current, latest, updateAvailable }
87
91
  ```
88
92
 
93
+ A passive once-a-day nudge prints one `stderr` line when a newer version is
94
+ published (`mm-harness <cur> → <latest> available · run: mm-harness update`). It
95
+ never blocks and never auto-updates. Silence it with `MM_HARNESS_NO_UPDATE_CHECK=1`
96
+ (auto-off in CI).
97
+
89
98
  Set `RECIPE_LOG_UI=compact|full|quiet` (default `compact`) and `RECIPE_LOG_EVENTS=10`
90
99
  to tune the compact log view during launch and Metro/webpack startup.
91
100
 
@@ -123,6 +132,24 @@ and shell launch in `adapters/porcelain/mm-recipe` / `adapters/porcelain/mme-rec
123
132
  primitives into `adapters/shared/`. CI installs harness from npm — publish
124
133
  `@farmslot/recipe-harness` before bumping the dependency here.
125
134
 
135
+ ## Dev/prod harness (`MM_HARNESS_BIN`)
136
+
137
+ The global npm install owns the `mm-harness` on `PATH` — that is the **prod**
138
+ harness. To run a **dev** checkout with active pre-release changes alongside it,
139
+ set `MM_HARNESS_BIN` to that checkout's `bin/mm-harness`:
140
+
141
+ ```bash
142
+ export MM_HARNESS_BIN=/path/to/checkout/bin/mm-harness # dev: this checkout owns the run
143
+ unset MM_HARNESS_BIN # prod: the installed/global bin
144
+ ```
145
+
146
+ `MM_HARNESS_BIN` is the single override point: when set, the entrypoint hands the
147
+ whole invocation to that path before doing any dependency work; when unset it
148
+ resolves the installed bin. A dev checkout is never installed globally, so there is
149
+ no `PATH` collision — dev is reached only through `MM_HARNESS_BIN` (or a shell
150
+ alias). A per-farm environment can export it so the dev farm dispatches through the
151
+ dev harness while the prod farm leaves it unset.
152
+
126
153
  ## Runtime notes
127
154
 
128
155
  - `tmux` is recommended for long-lived Metro/webpack processes; standalone use
@@ -176,6 +176,8 @@ ANDROID_PKGS=("${ANDROID_PACKAGE_ID:-io.metamask}" "io.metamask.flask" "io.metam
176
176
 
177
177
  if [ "$PLATFORM" = "ios" ]; then
178
178
  SIM_TARGET="$SIMULATOR"
179
+ # Expo dev-client URL scheme — drives both the deep link and the scheme-approval key.
180
+ DEV_CLIENT_SCHEME="${IOS_DEV_CLIENT_SCHEME:-expo-metamask}"
179
181
 
180
182
  boot_simulator_if_needed "$SIM_TARGET"
181
183
  show_simulator "$SIM_TARGET"
@@ -188,10 +190,18 @@ if [ "$PLATFORM" = "ios" ]; then
188
190
  xcrun simctl spawn "$SIM_TARGET" defaults write "$BUNDLE_ID" \
189
191
  EXDevMenuIsOnboardingFinished -bool YES 2>/dev/null || true
190
192
 
193
+ # Pre-approve the dev-client deep-link scheme so openurl never pauses on the
194
+ # "Open in <App>?" dialog. The key encodes the CoreSimulator bridge + scheme;
195
+ # the value is the bundle ID of the approved handler.
196
+ xcrun simctl spawn "$SIM_TARGET" defaults write \
197
+ com.apple.launchservices.schemeapproval \
198
+ "com.apple.CoreSimulator.CoreSimulatorBridge-->${DEV_CLIENT_SCHEME}" \
199
+ -string "$BUNDLE_ID" 2>/dev/null || true
200
+
191
201
  # Open via expo-development-client deep link so the app connects to our Metro.
192
202
  ENCODED_URL="$(urlencode "http://localhost:${PORT}?disableOnboarding=1" 2>/dev/null \
193
203
  || printf 'http%%3A%%2F%%2Flocalhost%%3A%s%%3FdisableOnboarding%%3D1' "$PORT")"
194
- DEEP_LINK="expo-metamask://expo-development-client/?url=${ENCODED_URL}"
204
+ DEEP_LINK="${DEV_CLIENT_SCHEME}://expo-development-client/?url=${ENCODED_URL}"
195
205
 
196
206
  printf 'Launching iOS dev client %s on %s\n' "$BUNDLE_ID" "$SIM_TARGET" >&2
197
207
  if xcrun simctl openurl "$SIM_TARGET" "$DEEP_LINK" 2>/dev/null; then
@@ -174,14 +174,21 @@ watcher_port() {
174
174
  const fs = require('fs');
175
175
  const path = require('path');
176
176
  const target = process.env.TARGET_FOR_WATCHER_PORT;
177
- let port = process.env.WATCHER_PORT || '8081';
178
- for (const file of ['.js.env', '.env', '.env.local']) {
179
- const full = path.join(target, file);
180
- if (!fs.existsSync(full)) continue;
181
- const text = fs.readFileSync(full, 'utf8');
182
- const match = text.match(/^\s*(?:export\s+)?WATCHER_PORT=(["']?)([0-9]+)\1/m);
183
- if (match) { port = match[2]; break; }
177
+ // Precedence: an explicit WATCHER_PORT in the process env (which carries the
178
+ // --watcher-port flag applied by launch) wins over the slot's .js.env, which wins
179
+ // over the 8081 default. This lets a run be pointed at an alternate Metro without
180
+ // editing a shared slot .js.env.
181
+ let port = process.env.WATCHER_PORT || '';
182
+ if (!port) {
183
+ for (const file of ['.js.env', '.env', '.env.local']) {
184
+ const full = path.join(target, file);
185
+ if (!fs.existsSync(full)) continue;
186
+ const text = fs.readFileSync(full, 'utf8');
187
+ const match = text.match(/^\s*(?:export\s+)?WATCHER_PORT=(["']?)([0-9]+)\1/m);
188
+ if (match) { port = match[2]; break; }
189
+ }
184
190
  }
191
+ if (!port) port = '8081';
185
192
  console.log(port);
186
193
  NODE
187
194
  }
package/bin/mm-harness CHANGED
@@ -18,6 +18,38 @@ done
18
18
  SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd -P)"
19
19
  RUNNER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20
20
 
21
+ # Dev/prod switch (the single override point). MM_HARNESS_BIN, when set, is the
22
+ # authoritative path to a dev checkout's bin/mm-harness with active pre-release
23
+ # changes; unset resolves the installed/global mm-harness (prod). Handing the
24
+ # invocation over here, before any dependency work, lets the dev checkout fully
25
+ # own the run. The self-path guard prevents an exec loop when MM_HARNESS_BIN
26
+ # already points at this script — including via a symlink (both sides are resolved
27
+ # to their real paths before comparing).
28
+ if [ -n "${MM_HARNESS_BIN:-}" ]; then
29
+ self_real="$SCRIPT_DIR/$(basename "$SOURCE")"
30
+ # Resolve MM_HARNESS_BIN through any symlink chain so a symlink-to-self is caught.
31
+ mm_bin_src="$MM_HARNESS_BIN"
32
+ while [ -L "$mm_bin_src" ]; do
33
+ mm_bin_dir="$(cd "$(dirname "$mm_bin_src")" && pwd -P)"
34
+ mm_bin_target="$(readlink "$mm_bin_src")"
35
+ if [[ "$mm_bin_target" == /* ]]; then
36
+ mm_bin_src="$mm_bin_target"
37
+ else
38
+ mm_bin_src="$mm_bin_dir/$mm_bin_target"
39
+ fi
40
+ done
41
+ bin_real_dir="$(cd "$(dirname "$mm_bin_src")" 2>/dev/null && pwd -P || true)"
42
+ bin_real="${bin_real_dir:+$bin_real_dir/$(basename "$mm_bin_src")}"
43
+ if [ "$bin_real" != "$self_real" ]; then
44
+ if [ ! -x "$MM_HARNESS_BIN" ]; then
45
+ echo "mm-harness: MM_HARNESS_BIN is not an executable: $MM_HARNESS_BIN" >&2
46
+ echo " Next: point MM_HARNESS_BIN at a checkout's bin/mm-harness, or unset it to use the global install" >&2
47
+ exit 1
48
+ fi
49
+ exec "$MM_HARNESS_BIN" "$@"
50
+ fi
51
+ fi
52
+
21
53
  ENSURE_DEPS="$RUNNER_DIR/adapters/shared/ensure-runner-deps.sh"
22
54
  if [ -f "$ENSURE_DEPS" ]; then
23
55
  # shellcheck disable=SC1090
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -18,6 +18,14 @@ import {
18
18
 
19
19
  export { type MobileRuntimeDecisionReport };
20
20
 
21
+ // Pod install (run by `yarn setup` and by the native `yarn start:ios|android`
22
+ // build) shells `node --print require.resolve('react-native-worklets-core')` and
23
+ // treats any output other than the exact string `undefined` as "module found".
24
+ // An inherited FORCE_COLOR makes node emit a colorized `undefined`, so VisionCamera
25
+ // misdetects the worklets pod, enables FrameProcessors, and fails on the missing
26
+ // pod. Force plain output for pod-triggering spawns so the probe reads `undefined`.
27
+ const POD_PROBE_ENV: Record<string, string> = { FORCE_COLOR: '0', NO_COLOR: '1' };
28
+
21
29
  export interface PrepareMobileOptions extends MobileRuntimeDecisionOptions {
22
30
  /** Pass through to leaf-script spawnScript calls so output is suppressed in --json mode. */
23
31
  json?: boolean;
@@ -159,9 +167,9 @@ function dispatchAction(
159
167
  const cwd = action.cwd ?? target;
160
168
  switch (action.id) {
161
169
  case 'yarn-setup': {
162
- // Leaf: install node_modules (deps missing or stale).
170
+ // Leaf: install node_modules (deps missing or stale); `yarn setup` runs pods.
163
171
  const leaf = path.join(runnerDir, 'adapters/mobile/yarn-setup.sh');
164
- return spawnScript(leaf, ['--target', cwd], target, json);
172
+ return spawnScript(leaf, ['--target', cwd], target, json, POD_PROBE_ENV);
165
173
  }
166
174
  case 'start-metro': {
167
175
  // Leaf: ensure Metro is running; argv may carry --clear for cache reset.
@@ -185,13 +193,16 @@ function dispatchAction(
185
193
  return spawnScript(leaf, ['--target', cwd, '--clear'], target, json);
186
194
  }
187
195
  case 'launch-mobile-runtime': {
188
- // Leaf: open the MetaMask Mobile dev client on the simulator/device.
196
+ // Leaf: open the MetaMask Mobile dev client on the simulator/device. In
197
+ // auto/rebuild modes this runs the native build (`yarn start:*`), which
198
+ // triggers pod install — pass the pod-probe env so it stays FORCE_COLOR-safe.
189
199
  const leaf = path.join(runnerDir, 'adapters/mobile/open-device.sh');
190
200
  return spawnScript(
191
201
  leaf,
192
202
  ['--platform', platform, '--target', cwd, '--preflight-mode', preflightMode],
193
203
  target,
194
204
  json,
205
+ POD_PROBE_ENV,
195
206
  );
196
207
  }
197
208
  case 'rebuild-native-dev-client': {
@@ -68,9 +68,31 @@ function resolveMetroLog(target: string, metroLog?: string): string | null {
68
68
  return fs.existsSync(abs) ? abs : null;
69
69
  }
70
70
 
71
+ // A metro.log is stale when the yarn install markers are newer — a yarn install
72
+ // happened after the last bundle run, so the log describes a now-superseded module
73
+ // tree. Ignoring it lets launch start fresh instead of deciding off stale content.
74
+ function isMetroLogStale(target: string, logAbs: string): boolean {
75
+ try {
76
+ const logMtime = fs.statSync(logAbs).mtimeMs;
77
+ // Check against the same install markers as depsCheck; they are updated by
78
+ // yarn on every successful install, making them a reliable freshness signal.
79
+ const INSTALL_MARKERS = ['node_modules/.yarn-state.yml', '.yarn/install-state.gz'];
80
+ return INSTALL_MARKERS.some((rel) => {
81
+ try {
82
+ return logMtime < fs.statSync(path.join(target, rel)).mtimeMs;
83
+ } catch {
84
+ return false;
85
+ }
86
+ });
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
71
92
  function metroLogCheck(target: string, metroLog?: string): MetroLogCheck {
72
93
  const abs = resolveMetroLog(target, metroLog);
73
94
  if (!abs) return { status: 'no-log' };
95
+ if (isMetroLogStale(target, abs)) return { status: 'no-log' };
74
96
  const logText = fs.readFileSync(abs, 'utf8');
75
97
  return analyzeBundleLog({
76
98
  target,
@@ -80,6 +102,54 @@ function metroLogCheck(target: string, metroLog?: string): MetroLogCheck {
80
102
  });
81
103
  }
82
104
 
105
+ // Returns names of top-level package.json dependencies absent from node_modules.
106
+ // Catches the case where a dep was added in a branch merge but yarn install was not
107
+ // re-run — install markers and fingerprint may both be satisfied while a required
108
+ // package directory is simply missing (real case: 249s Metro bundle discovers it
109
+ // too late).
110
+ function missingRequiredDeps(target: string): string[] {
111
+ try {
112
+ const pkg = JSON.parse(fs.readFileSync(path.join(target, 'package.json'), 'utf8')) as {
113
+ dependencies?: Record<string, unknown>;
114
+ devDependencies?: Record<string, unknown>;
115
+ };
116
+ const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
117
+ return Object.keys(all).filter(
118
+ (name) => !fs.existsSync(path.join(target, 'node_modules', name)),
119
+ );
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ // Returns true when any manifest input (package.json or yarn.lock) is newer than
126
+ // the install markers written by yarn on a successful install. A reliable signal
127
+ // that deps changed since the last install, even when depsCheck found no recorded
128
+ // baseline drift (e.g. git checkout preserved an old author-date mtime on the
129
+ // manifest, then a new dep was committed, making the manifest newer than markers).
130
+ function depsStaleByMtime(target: string): boolean {
131
+ // Use the same install markers as depsCheck: the files yarn writes on install.
132
+ const INSTALL_MARKERS = ['node_modules/.yarn-state.yml', '.yarn/install-state.gz'];
133
+ let markerMtime = 0;
134
+ for (const rel of INSTALL_MARKERS) {
135
+ try {
136
+ const mt = fs.statSync(path.join(target, rel)).mtimeMs;
137
+ if (mt > markerMtime) markerMtime = mt;
138
+ } catch {
139
+ // marker absent; keep searching
140
+ }
141
+ }
142
+ if (markerMtime === 0) return false; // no markers → depsCheck already handles via 'missing'
143
+ for (const file of ['package.json', 'yarn.lock']) {
144
+ try {
145
+ if (fs.statSync(path.join(target, file)).mtimeMs > markerMtime) return true;
146
+ } catch {
147
+ // manifest absent; skip
148
+ }
149
+ }
150
+ return false;
151
+ }
152
+
83
153
  const installActions = (target: string): MobileRuntimeDecisionAction[] => [
84
154
  { id: 'yarn-setup', argv: ['yarn', 'setup'], cwd: target },
85
155
  ];
@@ -108,9 +178,28 @@ export async function decideMobileReadiness(
108
178
  const resolved = path.resolve(target);
109
179
  if (options.record) recordDepsBaseline(resolved);
110
180
 
111
- const deps = depsCheck(resolved, {
181
+ const rawDeps = depsCheck(resolved, {
112
182
  productMarkers: mobileProductMarkers(options.platform),
113
183
  });
184
+ // Mtime-based fallback: when depsCheck found no recorded baseline, supplement
185
+ // with a direct mtime comparison — if package.json or yarn.lock is newer than
186
+ // node_modules, a dep change landed after the last install. Fail fast at
187
+ // pre-flight rather than launching a doomed Metro bundle. Skip when a baseline
188
+ // was found (hasBaseline=true) so fingerprint-verified 'current' is trusted.
189
+ let deps: DepsCheck =
190
+ rawDeps.status === 'current' && !rawDeps.hasBaseline && depsStaleByMtime(resolved)
191
+ ? { installed: rawDeps.installed, status: 'stale', hasBaseline: rawDeps.hasBaseline }
192
+ : rawDeps;
193
+ // Required-dep absence: even when fingerprint + mtime reports 'current', a
194
+ // top-level dependency could be absent from node_modules (e.g. added in a
195
+ // branch merge but install not re-run). Catch at pre-flight before Metro
196
+ // discovers it during a 249s bundle run.
197
+ if (deps.status === 'current') {
198
+ const absent = missingRequiredDeps(resolved);
199
+ if (absent.length > 0) {
200
+ deps = { installed: deps.installed, status: 'partial', hasBaseline: deps.hasBaseline, missingProducts: absent };
201
+ }
202
+ }
114
203
  const metroLog = metroLogCheck(resolved, options.metroLog);
115
204
  const metro = options.watcherPort
116
205
  ? await probeMetroPackager(options.watcherPort)
@@ -163,7 +252,10 @@ export async function decideMobileReadiness(
163
252
  }
164
253
 
165
254
  const metroLogAbs = resolveMetroLog(resolved, options.metroLog);
166
- const metroLogText = metroLogAbs ? fs.readFileSync(metroLogAbs, 'utf8') : '';
255
+ const metroLogText =
256
+ metroLogAbs && !isMetroLogStale(resolved, metroLogAbs)
257
+ ? fs.readFileSync(metroLogAbs, 'utf8')
258
+ : '';
167
259
  const staleNativeModule = supersededErrorCapture(metroLogText, {
168
260
  errorPattern: NATIVE_MODULE_STALE,
169
261
  okPattern: BUNDLE_OK,
package/src/cli.ts CHANGED
@@ -57,7 +57,7 @@ import type {
57
57
  RecipeValidationResult,
58
58
  } from '@farmslot/protocol';
59
59
  import type { MetaMaskRecipeAdapter } from './types.ts';
60
- import { usageOut } from './commands/shared.ts';
60
+ import { ADAPTER_DETECT_NEXT, usageOut } from './commands/shared.ts';
61
61
 
62
62
  type CliOptionValue = string | boolean;
63
63
  type CliOptions = Record<string, CliOptionValue>;
@@ -455,11 +455,7 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
455
455
  const explicitAdapter = optionString(options, 'adapter') ?? optionString(options, 'platform');
456
456
  const adapter = explicitAdapter ?? detectAdapter(target);
457
457
  if (!adapter) {
458
- return usageOut(
459
- json,
460
- 'doctor',
461
- `could not detect the MetaMask repo type for ${target}\n Next: pass --adapter mobile|extension|core`,
462
- );
458
+ return usageOut(json, 'doctor', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
463
459
  }
464
460
  assertAdapter(adapter);
465
461
  const actionManifestPath = actionManifestPathOption(options, adapter);
@@ -853,9 +849,7 @@ function resolveAdapter(options: CliOptions): { adapter: MetaMaskRecipeAdapter;
853
849
  const explicit = optionString(options, 'adapter') ?? optionString(options, 'platform');
854
850
  const adapter = explicit ?? detectAdapter(target);
855
851
  if (!adapter) {
856
- throw usageError(
857
- `could not detect the MetaMask repo type for ${target}\n Next: pass --adapter mobile|extension|core`,
858
- );
852
+ throw usageError(`could not detect the MetaMask repo type for ${target}\n Next: ${ADAPTER_DETECT_NEXT}`);
859
853
  }
860
854
  try {
861
855
  assertAdapter(adapter);
@@ -867,20 +861,24 @@ function resolveAdapter(options: CliOptions): { adapter: MetaMaskRecipeAdapter;
867
861
 
868
862
  // Adapter-aware recipe validation: schema (validateRecipeDocument) + action
869
863
  // existence/platform against the adapter's manifest (validateRecipeWithManifest).
870
- // This is the "run validates first" contract, shared by `run --plan` and `call`.
871
- //
872
- // NOTE (pinned deps): recipes that reference library flows by `call.ref` need
873
- // `externalFlowIds` sourced from resolveRecipeLibrarySources, which lands with
874
- // @farmslot/recipe-harness >= 0.3.3 (pending publish). Until then, self-contained
875
- // recipes (all in-repo recipes + every one-node `call` recipe) validate fully;
876
- // library-ref recipes would surface their refs here. Extends when 0.3.3 lands.
864
+ // When librarySources are provided (resolved from --library / personal / team
865
+ // tiers), external flow IDs from those sources are declared so `call.ref` nodes
866
+ // targeting library-defined flows pass schema validation.
877
867
  async function validateRecipeAdapterAware(
878
868
  recipe: unknown,
879
869
  manifest: RecipeActionManifestDocument,
870
+ librarySources?: MetaMaskLibrarySource[],
880
871
  ): Promise<RecipeValidationResult> {
881
872
  const { validateRecipeDocument, validateRecipeWithManifest } = await importRecipeProtocol();
882
- const schema = validateRecipeDocument(recipe);
883
- const withManifest = validateRecipeWithManifest(recipe, manifest);
873
+ let externalFlowIds: ReadonlySet<string> | undefined;
874
+ if (librarySources && librarySources.length > 0) {
875
+ const harness = await importRecipeHarness();
876
+ const resolution = await harness.loadRecipeLibraries(librarySources);
877
+ externalFlowIds = new Set(resolution.flows.keys());
878
+ }
879
+ const validationOptions = externalFlowIds !== undefined ? { externalFlowIds } : undefined;
880
+ const schema = validateRecipeDocument(recipe, validationOptions);
881
+ const withManifest = validateRecipeWithManifest(recipe, manifest, validationOptions);
884
882
  const findings = [...schema.findings, ...withManifest.findings];
885
883
  const errors = findings.filter((finding) => finding.severity === 'error').length;
886
884
  const warnings = findings.length - errors;
@@ -905,9 +903,10 @@ function countRecipeNodes(recipe: unknown): number | undefined {
905
903
  // Static, adapter-aware recipe validation shared by `run --plan` and the `run`
906
904
  // execute path so both "validate first" the same way: read the file (missing /
907
905
  // unparseable → usage error, nothing to validate), check the manifest, then run
908
- // the adapter-aware schema + action-existence validation. Touches nothing and
909
- // resolves no libraries, so it never emits the engine's "Recipe libraries: …"
910
- // log — safe to call before the machine-contract JSON is written.
906
+ // the adapter-aware schema + action-existence validation. Loads library flow IDs
907
+ // from --library (so `call.ref` nodes to external flows resolve correctly), but
908
+ // never emits the engine's "Recipe libraries: …" log — safe to call before the
909
+ // machine-contract JSON is written.
911
910
  interface RunRecipeStaticValidation {
912
911
  recipe: unknown;
913
912
  recipeFile: string;
@@ -958,8 +957,10 @@ async function validateRunRecipeStatic(
958
957
  }
959
958
 
960
959
  // validate.schema + validate.actions (static) — adapter-aware recipe validation.
960
+ // Library sources are resolved here so external `call.ref` flows validate correctly.
961
+ const librarySources = await resolveMetaMaskLibrarySources(optionString(options, 'library'));
961
962
  const validation = manifestOk
962
- ? await validateRecipeAdapterAware(recipe, manifest)
963
+ ? await validateRecipeAdapterAware(recipe, manifest, librarySources)
963
964
  : { status: 'invalid' as const, findings: [], summary: { errors: 1, warnings: 0 } };
964
965
  findings.push(...validation.findings);
965
966
 
@@ -5,7 +5,7 @@
5
5
  import path from 'node:path';
6
6
 
7
7
  import { runnerDir } from '../paths.ts';
8
- import { EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from './shared.ts';
8
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from './shared.ts';
9
9
 
10
10
  const DEBUG_BOOLEANS = new Set(['worker', 'devMenu', 'json']);
11
11
 
@@ -15,8 +15,8 @@ export async function handleDebug(argv: string[]): Promise<number> {
15
15
  const target = targetOf(options);
16
16
  const adapter = resolveAdapter(options, target);
17
17
 
18
- if (!adapter) return usageOut(json, 'debug', `could not detect the MetaMask repo type for ${target}`);
19
- if (adapter === 'core') return usageOut(json, 'debug', 'core is headless; there is no debug console.');
18
+ if (!adapter) return usageOut(json, 'debug', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
19
+ if (adapter === 'core') return usageOut(json, 'debug', 'core is headless; there is no debug console.', 'mm-harness verify');
20
20
 
21
21
  const worker = flag(options, 'worker');
22
22
  const devMenu = flag(options, 'devMenu');
@@ -24,14 +24,16 @@ export async function handleDebug(argv: string[]): Promise<number> {
24
24
  return usageOut(
25
25
  json,
26
26
  'debug',
27
- '--worker is extension-only (service-worker DevTools). Use --dev-menu on mobile.\n Next: mm-harness debug --dev-menu',
27
+ '--worker is extension-only (service-worker DevTools). Use --dev-menu on mobile.',
28
+ 'mm-harness debug --dev-menu',
28
29
  );
29
30
  }
30
31
  if (adapter === 'extension' && devMenu) {
31
32
  return usageOut(
32
33
  json,
33
34
  'debug',
34
- '--dev-menu is mobile-only (RN developer menu). Use --worker on the extension.\n Next: mm-harness debug --worker',
35
+ '--dev-menu is mobile-only (RN developer menu). Use --worker on the extension.',
36
+ 'mm-harness debug --worker',
35
37
  );
36
38
  }
37
39
 
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
 
8
8
  import { runnerDir, walletFixturePath } from '../paths.ts';
9
9
  import type { MetaMaskRecipeAdapter } from '../types.ts';
10
- import { EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
10
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
11
11
 
12
12
  // One node injected from cli.ts so `fixtures set` can reuse the real engine path
13
13
  // (`call`'s one-node machinery) without a circular import.
@@ -48,18 +48,14 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
48
48
  const json = flag(options, 'json');
49
49
  const sub = positional[0];
50
50
  if (sub !== 'sync' && sub !== 'set') {
51
- return usageOut(json, 'fixtures', 'fixtures requires a subcommand: mm-harness fixtures <sync|set>\n Next: mm-harness fixtures sync or mm-harness fixtures set');
51
+ return usageOut(json, 'fixtures', 'fixtures requires a subcommand: mm-harness fixtures <sync|set>', 'mm-harness fixtures sync or mm-harness fixtures set');
52
52
  }
53
53
  const target = targetOf(options);
54
54
  const adapter = resolveAdapter(options, target);
55
55
  if (!adapter) {
56
- return usageOut(
57
- json,
58
- 'fixtures',
59
- `could not detect the MetaMask repo type for ${target}\n Next: pass --target <checkout> or cd into the checkout, then re-run: mm-harness fixtures ${sub}`,
60
- );
56
+ return usageOut(json, 'fixtures', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
61
57
  }
62
- if (adapter === 'core') return usageOut(json, 'fixtures', 'core is headless; it has no wallet fixture.\n Next: mm-harness run <recipe> # run recipes against the headless core');
58
+ if (adapter === 'core') return usageOut(json, 'fixtures', 'core is headless; it has no wallet fixture.', 'mm-harness run <recipe> # run recipes against the headless core');
63
59
 
64
60
  const canonicalFixture = walletFixturePath(target);
65
61
 
@@ -117,7 +113,8 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
117
113
  return usageOut(
118
114
  json,
119
115
  'fixtures',
120
- `no wallet fixture at ${fixturePath}.\n Next: create it (or pass --fixture <path>), then re-run: mm-harness fixtures set`,
116
+ `no wallet fixture at ${fixturePath}.`,
117
+ 'create it (or pass --fixture <path>), then re-run: mm-harness fixtures set',
121
118
  );
122
119
  }
123
120
  const previousFixtureEnv = process.env.RECIPE_WALLET_FIXTURE;
@@ -13,6 +13,7 @@ import { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, runnerDir } fro
13
13
  import type { MetaMaskRecipeAdapter } from '../types.ts';
14
14
  import { prepareMobile } from '../adapters/mobile/prepare.ts';
15
15
  import {
16
+ ADAPTER_DETECT_NEXT,
16
17
  EXIT,
17
18
  flag,
18
19
  parseFlags,
@@ -60,7 +61,7 @@ export async function handleLaunch(argv: string[]): Promise<number> {
60
61
  const target = targetOf(options);
61
62
 
62
63
  const heal = parseHeal(options, 'auto');
63
- if (typeof heal !== 'string') return usageOut(json, 'launch', heal.error);
64
+ if (typeof heal !== 'string') return usageOut(json, 'launch', heal.error, 'use --heal off|infra-only|auto');
64
65
 
65
66
  // Grammar: mobile requires an ios|android positional (also disambiguates the
66
67
  // adapter); extension takes no positional; core is headless (teaching error).
@@ -74,20 +75,17 @@ export async function handleLaunch(argv: string[]): Promise<number> {
74
75
  const adapter = resolveAdapter(options, target, adapterHint);
75
76
 
76
77
  if (!adapter) {
77
- return usageOut(
78
- json,
79
- 'launch',
80
- `could not detect the MetaMask repo type for ${target}\n Next: pass --adapter mobile|extension, or run inside a checkout.`,
81
- );
78
+ return usageOut(json, 'launch', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
82
79
  }
83
80
  if (adapter === 'core') {
84
- return usageOut(json, 'launch', 'core is headless; there is nothing to launch. Use: mm-harness verify');
81
+ return usageOut(json, 'launch', 'core is headless; there is nothing to launch.', 'mm-harness verify');
85
82
  }
86
83
  if (adapter === 'mobile' && !mobileTarget) {
87
84
  return usageOut(
88
85
  json,
89
86
  'launch',
90
- 'target is required for mobile.\n Run: mm-harness launch ios or mm-harness launch android',
87
+ 'target is required for mobile.',
88
+ 'mm-harness launch ios or mm-harness launch android',
91
89
  );
92
90
  }
93
91
 
@@ -98,7 +96,7 @@ export async function handleLaunch(argv: string[]): Promise<number> {
98
96
 
99
97
  // --runway is mobile-only.
100
98
  if (wantRunway && adapter !== 'mobile') {
101
- return usageOut(json, 'launch', 'runway is mobile-only. Drop --runway for the extension.');
99
+ return usageOut(json, 'launch', 'runway is mobile-only.', 'drop --runway for the extension');
102
100
  }
103
101
  // --fullscreen is the explicit override when both display flags are passed.
104
102
  const displayMode =
@@ -111,7 +109,12 @@ export async function handleLaunch(argv: string[]): Promise<number> {
111
109
  for (const portFlag of ['cdpPort', 'watcherPort'] as const) {
112
110
  const value = str(options, portFlag);
113
111
  if (value !== undefined && !/^\d+$/u.test(value)) {
114
- return usageOut(json, 'launch', `--${portFlag === 'cdpPort' ? 'cdp-port' : 'watcher-port'} must be numeric (got: ${value}).`);
112
+ return usageOut(
113
+ json,
114
+ 'launch',
115
+ `--${portFlag === 'cdpPort' ? 'cdp-port' : 'watcher-port'} must be numeric (got: ${value}).`,
116
+ `pass a numeric port, e.g. --${portFlag === 'cdpPort' ? 'cdp-port 6663' : 'watcher-port 8081'}`,
117
+ );
115
118
  }
116
119
  }
117
120
  applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
@@ -125,9 +128,8 @@ export async function handleLaunch(argv: string[]): Promise<number> {
125
128
  return usageOut(
126
129
  json,
127
130
  'launch',
128
- `native build inputs changed since last build — run: mm-harness launch ${
129
- adapter === 'mobile' ? `${mobileTarget} ` : ''
130
- }--build`,
131
+ 'native build inputs changed since last build.',
132
+ `mm-harness launch ${adapter === 'mobile' ? `${mobileTarget} ` : ''}--build`,
131
133
  );
132
134
  }
133
135
 
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  import { recipeRuntimePath, runnerDir } from '../paths.ts';
9
- import { EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
9
+ import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
10
10
 
11
11
  const LOGS_BOOLEANS = new Set(['full', 'json']);
12
12
 
@@ -17,19 +17,15 @@ export async function handleLogs(argv: string[]): Promise<number> {
17
17
  const adapter = resolveAdapter(options, target);
18
18
 
19
19
  if (!adapter) {
20
- return usageOut(
21
- json,
22
- 'logs',
23
- `could not detect the MetaMask repo type for ${target}\n Next: pass --target <checkout> or cd into the checkout, then re-run: mm-harness logs`,
24
- );
20
+ return usageOut(json, 'logs', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
25
21
  }
26
22
  if (adapter === 'core') {
27
- return usageOut(json, 'logs', 'core is headless; there are no app/Metro logs.\n Next: mm-harness run <recipe> # run recipes against the headless core');
23
+ return usageOut(json, 'logs', 'core is headless; there are no app/Metro logs.', 'mm-harness run <recipe> # run recipes against the headless core');
28
24
  }
29
25
 
30
26
  const source = str(options, 'source') ?? 'metro';
31
27
  if (source !== 'metro' && source !== 'app') {
32
- return usageOut(json, 'logs', '--source must be metro or app.\n Next: mm-harness logs --source metro or mm-harness logs --source app');
28
+ return usageOut(json, 'logs', '--source must be metro or app.', 'mm-harness logs --source metro or mm-harness logs --source app');
33
29
  }
34
30
 
35
31
  // Env-gap flag (docs/CLI-SPEC.md Part 4): --events sets the compact event count
@@ -38,7 +34,7 @@ export async function handleLogs(argv: string[]): Promise<number> {
38
34
  const events = str(options, 'events');
39
35
  if (events !== undefined) {
40
36
  if (!/^\d+$/u.test(events)) {
41
- return usageOut(json, 'logs', `--events must be numeric (got: ${events}).\n Next: mm-harness logs --events 20`);
37
+ return usageOut(json, 'logs', `--events must be numeric (got: ${events}).`, 'mm-harness logs --events 20');
42
38
  }
43
39
  process.env.RECIPE_LOG_EVENTS = events;
44
40
  }
@@ -51,7 +47,8 @@ export async function handleLogs(argv: string[]): Promise<number> {
51
47
  return usageOut(
52
48
  json,
53
49
  'logs',
54
- `nothing running for this checkout (no ${path.basename(logFile)}).\n Next: ${launchHint}`,
50
+ `nothing running for this checkout (no ${path.basename(logFile)}).`,
51
+ launchHint,
55
52
  );
56
53
  }
57
54
 
@@ -95,12 +95,14 @@ export interface ScriptResult {
95
95
 
96
96
  // Compose an adapters/ script directly. Output is always captured (needed for
97
97
  // heal classification) and, in human mode, forwarded to stderr. --json keeps
98
- // stdout clean for the machine summary.
98
+ // stdout clean for the machine summary. `env` overlays extra vars onto the
99
+ // inherited environment for spawns that need a scoped variable (e.g. color mode).
99
100
  export function spawnScript(
100
101
  script: string,
101
102
  args: string[],
102
103
  cwd: string,
103
104
  json: boolean,
105
+ env?: Record<string, string>,
104
106
  ): ScriptResult {
105
107
  // For node invocations (script === process.execPath) the seam stem is derived
106
108
  // from args[0] so each spawned script has its own override key.
@@ -114,7 +116,7 @@ export function spawnScript(
114
116
  const result = spawnSync(bin, spawnArgs, {
115
117
  cwd,
116
118
  encoding: 'utf8',
117
- env: process.env,
119
+ env: env ? { ...process.env, ...env } : process.env,
118
120
  maxBuffer: 64 * 1024 * 1024,
119
121
  });
120
122
  if (result.error) {
@@ -125,14 +127,24 @@ export function spawnScript(
125
127
  return { status: result.status ?? 1, output };
126
128
  }
127
129
 
128
- // Teaching-error emitter (exit 2, machine-readable in --json).
129
- export function usageOut(json: boolean, command: string, message: string): number {
130
+ // Both escapes from a failed repo-type detection (defined at the detection source
131
+ // in harness.ts): where to run it (checkout/target) and how to force it (adapter).
132
+ export { ADAPTER_DETECT_NEXT } from '../harness.ts';
133
+
134
+ // Teaching-error emitter (exit 2, machine-readable in --json). `userAction` is
135
+ // REQUIRED: a teaching error without a reachable escape must not compile, so the
136
+ // escape is a typed parameter rather than free-form prose spliced into `message`.
137
+ export function usageOut(json: boolean, command: string, message: string, userAction: string): number {
130
138
  if (json) {
131
139
  console.log(
132
- JSON.stringify({ schemaVersion: 1, command, status: 'fail', exitCode: EXIT.usage, error: { code: 'USAGE', message } }, null, 2),
140
+ JSON.stringify(
141
+ { schemaVersion: 1, command, status: 'fail', exitCode: EXIT.usage, error: { code: 'USAGE', message, userAction } },
142
+ null,
143
+ 2,
144
+ ),
133
145
  );
134
146
  } else {
135
- console.error(`✗ mm-harness ${command}: ${message}`);
147
+ console.error(`✗ mm-harness ${command}: ${message}\n Next: ${userAction}`);
136
148
  }
137
149
  return EXIT.usage;
138
150
  }
@@ -0,0 +1,316 @@
1
+ // update — keep the globally-installed mm-harness current against the npm registry.
2
+ // `mm-harness update` upgrades to the published latest; `--check` reports without
3
+ // touching anything; `--json` carries { current, latest, updateAvailable }. A
4
+ // passive once-a-day nudge (maybeNudge) prints a single stderr line when a newer
5
+ // version exists — it never blocks, never auto-updates, and stays off stdout so
6
+ // the --json contract of every other command is untouched. Every failure path
7
+ // (no npm, registry unreachable, permission-denied global dir) names its escape.
8
+ //
9
+ // Two fetch strategies:
10
+ // fetchLatest — spawnSync(`npm view`) used by the explicit `update` command;
11
+ // respects the user's npm registry config; bounded by timeoutMs.
12
+ // fetchLatestAsync — native fetch() used by the passive nudge; never spawns a
13
+ // subprocess; hard-aborts at timeoutMs so it cannot block the
14
+ // event loop beyond that bound.
15
+
16
+ import { spawnSync } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ import { runnerDir } from '../paths.ts';
22
+ import { EXIT, flag, parseFlags } from './shared.ts';
23
+
24
+ export const PACKAGE_NAME = '@deeeed/metamask-harness';
25
+ const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1000;
26
+ const NUDGE_FETCH_TIMEOUT_MS = 300;
27
+
28
+ export function currentVersion(): string {
29
+ try {
30
+ const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, 'package.json'), 'utf8')) as { version?: string };
31
+ return pkg.version ?? '0.0.0';
32
+ } catch {
33
+ return '0.0.0';
34
+ }
35
+ }
36
+
37
+ function parseVersion(value: string): { nums: number[]; pre: string } {
38
+ const cleaned = value.replace(/^v/u, '');
39
+ const dash = cleaned.indexOf('-');
40
+ const core = dash === -1 ? cleaned : cleaned.slice(0, dash);
41
+ const pre = dash === -1 ? '' : cleaned.slice(dash + 1);
42
+ const nums = core.split('.').map((part) => Number.parseInt(part, 10) || 0);
43
+ while (nums.length < 3) nums.push(0);
44
+ return { nums, pre };
45
+ }
46
+
47
+ // True when `candidate` is a strictly higher version than `base`. A release outranks
48
+ // a prerelease of the same core (1.0.0 > 1.0.0-rc.1); prereleases compare lexically.
49
+ export function isNewer(candidate: string, base: string): boolean {
50
+ const a = parseVersion(candidate);
51
+ const b = parseVersion(base);
52
+ for (let i = 0; i < 3; i += 1) {
53
+ if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
54
+ }
55
+ if (a.pre === b.pre) return false;
56
+ if (a.pre === '') return true;
57
+ if (b.pre === '') return false;
58
+ return a.pre > b.pre;
59
+ }
60
+
61
+ export interface FetchResult {
62
+ latest?: string;
63
+ error?: { kind: 'no-npm' | 'unreachable'; message: string };
64
+ }
65
+
66
+ // Query the registry dist-tag `latest` via `npm view`. `timeoutMs` bounds the
67
+ // wait; callers MUST pass a finite value — an unbounded registry call can hang
68
+ // indefinitely. When the timeout fires, spawnSync sets result.signal to SIGTERM.
69
+ export function fetchLatest(timeoutMs: number): FetchResult {
70
+ const result = spawnSync('npm', ['view', PACKAGE_NAME, 'dist-tags.latest'], {
71
+ encoding: 'utf8',
72
+ timeout: timeoutMs,
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ });
75
+ if (result.error) {
76
+ const code = (result.error as NodeJS.ErrnoException).code;
77
+ if (code === 'ENOENT') return { error: { kind: 'no-npm', message: 'npm was not found on PATH' } };
78
+ return { error: { kind: 'unreachable', message: result.error.message } };
79
+ }
80
+ if (result.signal) {
81
+ return { error: { kind: 'unreachable', message: `registry fetch timed out after ${timeoutMs}ms` } };
82
+ }
83
+ if (result.status !== 0) {
84
+ return { error: { kind: 'unreachable', message: (result.stderr ?? '').trim() || `npm view exited ${result.status}` } };
85
+ }
86
+ const latest = (result.stdout ?? '').trim();
87
+ if (!latest) return { error: { kind: 'unreachable', message: 'npm returned no dist-tag for latest' } };
88
+ return { latest };
89
+ }
90
+
91
+ // Async variant for the passive nudge: uses the native fetch() API so it never
92
+ // spawns a subprocess and never blocks the event loop. Hard-aborts at timeoutMs.
93
+ // Skips silently on any error (nudge is best-effort).
94
+ async function fetchLatestAsync(timeoutMs: number): Promise<FetchResult> {
95
+ const controller = new AbortController();
96
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
97
+ try {
98
+ const url = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
99
+ const res = await fetch(url, { signal: controller.signal });
100
+ if (!res.ok) return { error: { kind: 'unreachable', message: `registry returned HTTP ${res.status}` } };
101
+ const data = (await res.json()) as Record<string, string>;
102
+ const latest = data['latest'];
103
+ if (!latest) return { error: { kind: 'unreachable', message: 'npm registry returned no latest dist-tag' } };
104
+ return { latest };
105
+ } catch (err) {
106
+ const msg = (err as Error).name === 'AbortError'
107
+ ? `registry fetch timed out after ${timeoutMs}ms`
108
+ : (err as Error).message;
109
+ return { error: { kind: 'unreachable', message: msg } };
110
+ } finally {
111
+ clearTimeout(timer);
112
+ }
113
+ }
114
+
115
+ // Non-zero on every failure (EXIT.infra = 3) so it never collides with `--check`'s
116
+ // exit 1 = "update available"; each carries the exact reachable escape.
117
+ function teachFailure(
118
+ json: boolean,
119
+ current: string,
120
+ code: string,
121
+ message: string,
122
+ userAction: string,
123
+ ): number {
124
+ if (json) {
125
+ console.log(
126
+ JSON.stringify(
127
+ { schemaVersion: 1, command: 'update', status: 'fail', current, error: { code, message, userAction } },
128
+ null,
129
+ 2,
130
+ ),
131
+ );
132
+ } else {
133
+ console.error(`✗ mm-harness update: ${message}\n Next: ${userAction}`);
134
+ }
135
+ return EXIT.infra;
136
+ }
137
+
138
+ function emitFetchError(json: boolean, error: NonNullable<FetchResult['error']>, current: string): number {
139
+ if (error.kind === 'no-npm') {
140
+ return teachFailure(
141
+ json,
142
+ current,
143
+ 'NO_NPM',
144
+ 'npm was not found on PATH — cannot check for updates',
145
+ 'install Node.js (which bundles npm) from https://nodejs.org, then re-run: mm-harness update',
146
+ );
147
+ }
148
+ return teachFailure(
149
+ json,
150
+ current,
151
+ 'REGISTRY_UNREACHABLE',
152
+ `could not reach the npm registry (${error.message})`,
153
+ 'check your network, then re-run: mm-harness update — or inspect the registry with: npm config get registry',
154
+ );
155
+ }
156
+
157
+ export async function handleUpdate(argv: string[]): Promise<number> {
158
+ const { options } = parseFlags(argv, new Set(['check', 'json']));
159
+ const json = flag(options, 'json');
160
+ const checkOnly = flag(options, 'check');
161
+ const current = currentVersion();
162
+
163
+ const fetched = fetchLatest(30_000);
164
+ if (fetched.error) return emitFetchError(json, fetched.error, current);
165
+ const latest = fetched.latest as string;
166
+ const updateAvailable = isNewer(latest, current);
167
+
168
+ if (checkOnly) {
169
+ if (json) {
170
+ console.log(JSON.stringify({ schemaVersion: 1, command: 'update', current, latest, updateAvailable }, null, 2));
171
+ } else if (updateAvailable) {
172
+ console.error(`mm-harness ${current} → ${latest} available · run: mm-harness update`);
173
+ } else {
174
+ console.log(`mm-harness is up to date (${current}).`);
175
+ }
176
+ // exit 1 signals "update available"; exit 0 signals up-to-date.
177
+ return updateAvailable ? EXIT.runtime : EXIT.ok;
178
+ }
179
+
180
+ if (!updateAvailable) {
181
+ if (json) {
182
+ console.log(JSON.stringify({ schemaVersion: 1, command: 'update', current, latest, updateAvailable: false, updated: false }, null, 2));
183
+ } else {
184
+ console.log(`mm-harness is up to date (${current}).`);
185
+ }
186
+ return EXIT.ok;
187
+ }
188
+
189
+ const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
190
+ const install = spawnSync('npm', ['i', '-g', `${PACKAGE_NAME}@latest`], {
191
+ encoding: 'utf8',
192
+ timeout: INSTALL_TIMEOUT_MS,
193
+ stdio: ['ignore', 'pipe', 'pipe'],
194
+ });
195
+ if (install.error) {
196
+ const code = (install.error as NodeJS.ErrnoException).code;
197
+ if (code === 'ENOENT') return emitFetchError(json, { kind: 'no-npm', message: 'npm was not found on PATH' }, current);
198
+ return teachFailure(json, current, 'INSTALL_FAILED', `global install failed (${install.error.message})`, `run it directly to see the error: npm i -g ${PACKAGE_NAME}@latest`);
199
+ }
200
+ if (install.signal) {
201
+ return teachFailure(
202
+ json,
203
+ current,
204
+ 'INSTALL_FAILED',
205
+ `global install timed out after ${INSTALL_TIMEOUT_MS / 1000}s — slow network or registry unavailable`,
206
+ `check your network, then retry: mm-harness update — or install manually: npm i -g ${PACKAGE_NAME}@latest`,
207
+ );
208
+ }
209
+ const combined = `${install.stdout ?? ''}${install.stderr ?? ''}`;
210
+ if (install.status !== 0) {
211
+ if (/EACCES|permission denied|EPERM/iu.test(combined)) {
212
+ return teachFailure(
213
+ json,
214
+ current,
215
+ 'GLOBAL_DIR_PERMISSION',
216
+ 'global install denied — the npm global directory is not writable',
217
+ 'either re-run with sudo, or point npm at a user-writable prefix: npm config set prefix ~/.npm-global (then add ~/.npm-global/bin to PATH) and re-run: mm-harness update',
218
+ );
219
+ }
220
+ return teachFailure(
221
+ json,
222
+ current,
223
+ 'INSTALL_FAILED',
224
+ `global install failed (${combined.trim() || `npm exited ${install.status}`})`,
225
+ `run it directly to see the error: npm i -g ${PACKAGE_NAME}@latest`,
226
+ );
227
+ }
228
+
229
+ // Re-check the registry so the reported new version is the one that resolved,
230
+ // not merely the tag we asked for. Best-effort — fall back to the tag if slow.
231
+ const rechecked = fetchLatest(30_000);
232
+ const installed = rechecked.latest ?? latest;
233
+ if (json) {
234
+ console.log(JSON.stringify({ schemaVersion: 1, command: 'update', current, latest: installed, updateAvailable: true, updated: true, from: current, to: installed }, null, 2));
235
+ } else {
236
+ console.log(`mm-harness updated ${current} → ${installed}.`);
237
+ }
238
+ return EXIT.ok;
239
+ }
240
+
241
+ // --- Passive update nudge ----------------------------------------------------
242
+
243
+ interface NudgeCache {
244
+ lastCheck: number;
245
+ latest: string;
246
+ }
247
+
248
+ function cacheFile(): string {
249
+ const override = process.env.MM_HARNESS_UPDATE_CACHE;
250
+ if (override) return override;
251
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
252
+ return path.join(base, 'mm-harness', 'update-check.json');
253
+ }
254
+
255
+ function readCache(file: string): NudgeCache | null {
256
+ try {
257
+ const data = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<NudgeCache>;
258
+ if (typeof data.lastCheck === 'number' && typeof data.latest === 'string') {
259
+ return { lastCheck: data.lastCheck, latest: data.latest };
260
+ }
261
+ } catch {
262
+ // Missing or corrupt cache = no prior state; a fresh probe will rebuild it.
263
+ }
264
+ return null;
265
+ }
266
+
267
+ function writeCache(file: string, cache: NudgeCache): void {
268
+ try {
269
+ fs.mkdirSync(path.dirname(file), { recursive: true });
270
+ fs.writeFileSync(file, JSON.stringify(cache));
271
+ } catch {
272
+ // Best-effort; a cache we cannot persist just means the next run re-probes.
273
+ }
274
+ }
275
+
276
+ // Suppressed in CI and when the user opts out — the nudge is for interactive use.
277
+ export function nudgeDisabled(): boolean {
278
+ return process.env.MM_HARNESS_NO_UPDATE_CHECK === '1' || Boolean(process.env.CI);
279
+ }
280
+
281
+ // Pure decision, testable without a clock or network.
282
+ export function nudgeLine(current: string, latest: string): string | null {
283
+ return isNewer(latest, current) ? `mm-harness ${current} → ${latest} available · run: mm-harness update` : null;
284
+ }
285
+
286
+ // One throttled, bounded, stderr-only line when a newer version is published.
287
+ // Called via setImmediate in mm-harness-cli.ts so no nudge code runs before
288
+ // parse/dispatch starts. Prints AFTER command output via process.once('exit'),
289
+ // giving cleaner UX than mid-output interleaving. The `latest` closure variable
290
+ // is captured by the exit handler; an async probe that resolves before
291
+ // process.exit() fires updates it automatically. Offline / timeout / no network
292
+ // skip silently. Cache timestamp is stamped before the fetch so an abandoned
293
+ // probe (process exits within 300ms) does not re-probe on the next invocation.
294
+ export async function maybeNudge(now: number = Date.now()): Promise<void> {
295
+ if (nudgeDisabled()) return;
296
+ const file = cacheFile();
297
+ const cache = readCache(file);
298
+ const current = currentVersion();
299
+ // Mutable: exit handler closes over this so a probe result that arrives before
300
+ // process.exit() automatically updates what gets printed.
301
+ let latest = cache?.latest ?? '';
302
+ // Register once — checks nudgeLine at exit time, picking up the freshest value.
303
+ process.once('exit', () => {
304
+ const line = nudgeLine(current, latest);
305
+ if (line) process.stderr.write(`${line}\n`);
306
+ });
307
+ if (!cache || now - cache.lastCheck >= NUDGE_INTERVAL_MS) {
308
+ // Stamp before the fetch so a killed probe does not re-probe next run.
309
+ writeCache(file, { lastCheck: now, latest });
310
+ const fetched = await fetchLatestAsync(NUDGE_FETCH_TIMEOUT_MS);
311
+ if (fetched.latest) {
312
+ latest = fetched.latest;
313
+ writeCache(file, { lastCheck: now, latest });
314
+ }
315
+ }
316
+ }
package/src/harness.ts CHANGED
@@ -156,6 +156,12 @@ function parseHarnessArgs(args: string[]): ParsedHarnessArgs {
156
156
  // Mirror of scripts/lib/cli-common.sh detect_adapter for the auto-detect path.
157
157
  // Exported so `doctor` shares the exact same detect-from-target logic as the
158
158
  // overlay commands (verify/install/cleanup) — one auto-detect story, no drift.
159
+ // Both escapes from a failed repo-type detection: where to run it (checkout/target)
160
+ // and how to force it (adapter). A single source so every detect-failure teaches
161
+ // the same reachable next step.
162
+ export const ADAPTER_DETECT_NEXT =
163
+ 'cd into a MetaMask checkout or pass --target <path>, or force it with --adapter <mobile|extension|core>';
164
+
159
165
  export function detectAdapter(target: string): MetaMaskRecipeAdapter | undefined {
160
166
  let remote = '';
161
167
  try {
@@ -395,6 +401,7 @@ async function handleMobileLive(
395
401
  harnessSummary('live', 'mobile', target, 'fail', prepResult.status, autoDetected, {
396
402
  code: 'MOBILE_PREPARE_FAILED',
397
403
  message: `mobile prepare failed (exit ${prepResult.status})`,
404
+ userAction: failureHint('mobile', 'live'),
398
405
  }),
399
406
  );
400
407
  } else {
@@ -433,7 +440,7 @@ async function handleMobileLive(
433
440
  autoDetected,
434
441
  exitCode === 0
435
442
  ? undefined
436
- : { code: 'MOBILE_VERIFY_FAILED', message: `mobile live verify failed (exit ${exitCode})` },
443
+ : { code: 'MOBILE_VERIFY_FAILED', message: `mobile live verify failed (exit ${exitCode})`, userAction: failureHint('mobile', 'live') },
437
444
  ),
438
445
  );
439
446
  } else if (exitCode === 0) {
@@ -470,15 +477,15 @@ export async function handleHarness(argv: string[]): Promise<number> {
470
477
  // parsedAdapter is string|undefined here; not yet validated as a real
471
478
  // adapter, so pass undefined rather than forwarding a garbage string.
472
479
  const detectError = adapter
473
- ? { code: 'UNSUPPORTED_PLATFORM', message: `unsupported platform: ${adapter}` }
474
- : { code: 'ADAPTER_DETECTION_FAILED', message: `could not detect the MetaMask repo type for ${target}` };
480
+ ? { code: 'UNSUPPORTED_PLATFORM', message: `unsupported platform: ${adapter}`, userAction: 'pass --adapter <mobile|extension|core> to specify a supported adapter' }
481
+ : { code: 'ADAPTER_DETECTION_FAILED', message: `could not detect the MetaMask repo type for ${target}`, userAction: ADAPTER_DETECT_NEXT };
475
482
  console.log(harnessSummary(harnessAction, undefined, target, 'fail', 2, false, detectError));
476
483
  } else {
477
484
  harnessUsage();
478
485
  console.error(
479
486
  adapter
480
487
  ? `\n✗ unsupported platform: ${adapter}`
481
- : `\n✗ could not detect the MetaMask repo type for ${target}\n Next: pass --platform mobile|extension|core`,
488
+ : `\n✗ could not detect the MetaMask repo type for ${target}\n Next: ${ADAPTER_DETECT_NEXT}`,
482
489
  );
483
490
  }
484
491
  return 2;
@@ -489,6 +496,7 @@ export async function handleHarness(argv: string[]): Promise<number> {
489
496
  harnessSummary(harnessAction, adapter, target, 'fail', 2, parsedAdapter === undefined, {
490
497
  code: 'UNSUPPORTED_ACTION',
491
498
  message: `${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
499
+ userAction: 'run mm-harness doctor to see which actions are supported for this adapter',
492
500
  }),
493
501
  );
494
502
  } else {
@@ -519,6 +527,9 @@ export async function handleHarness(argv: string[]): Promise<number> {
519
527
  harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
520
528
  code: 'DISPATCH_UNAVAILABLE',
521
529
  message: dispatch.error,
530
+ // The dispatch error embeds "Next: <hint>" — extract it so --json consumers
531
+ // get a clean programmatic escape without parsing the human message.
532
+ userAction: dispatch.error.split('\nNext: ')[1] ?? 'run mm-harness install to complete setup, then retry',
522
533
  }),
523
534
  );
524
535
  } else {
@@ -547,6 +558,7 @@ export async function handleHarness(argv: string[]): Promise<number> {
547
558
  harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
548
559
  code: 'HARNESS_SPAWN_FAILED',
549
560
  message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
561
+ userAction: failureHint(adapter, harnessAction),
550
562
  }),
551
563
  );
552
564
  } else {
@@ -567,7 +579,7 @@ export async function handleHarness(argv: string[]): Promise<number> {
567
579
  autoDetected,
568
580
  exitCode === 0
569
581
  ? undefined
570
- : { code: 'HARNESS_FAILED', message: `${harnessAction} ${adapter} failed (exit ${exitCode})` },
582
+ : { code: 'HARNESS_FAILED', message: `${harnessAction} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(adapter, harnessAction) },
571
583
  ),
572
584
  );
573
585
  } else if (exitCode === 0) {
@@ -578,6 +590,9 @@ export async function handleHarness(argv: string[]): Promise<number> {
578
590
  return exitCode;
579
591
  }
580
592
 
593
+ // userAction is required whenever an error object is present so every --json
594
+ // failure carries a machine-readable escape path — parallel to the `usageOut`
595
+ // enforcement on the CLI layer. Omitting userAction is a compile-time error.
581
596
  function harnessSummary(
582
597
  action: HarnessAction,
583
598
  adapter: MetaMaskRecipeAdapter | undefined,
@@ -585,7 +600,7 @@ function harnessSummary(
585
600
  status: 'pass' | 'fail',
586
601
  exitCode: number,
587
602
  autoDetected: boolean,
588
- error?: { code: string; message: string },
603
+ error?: { code: string; message: string; userAction: string },
589
604
  ): string {
590
605
  return JSON.stringify({
591
606
  schemaVersion: 1,
@@ -597,7 +612,8 @@ function harnessSummary(
597
612
  status,
598
613
  exitCode,
599
614
  // Error contract: every --json failure carries a stable machine code + human
600
- // message (CLI-SPEC.md §5.1).
615
+ // message (CLI-SPEC.md §5.1). userAction is included when present so callers
616
+ // can surface the reachable escape without parsing the human message.
601
617
  ...(status === 'fail' && error ? { error } : {}),
602
618
  });
603
619
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
+ import path from 'node:path';
10
11
 
11
12
  import { handleHarness } from './harness.ts';
12
13
  import { recipeHarnessPath, recipeRuntimePath } from './paths.ts';
@@ -54,6 +55,24 @@ function overlayPresent(target: string, adapter: MetaMaskRecipeAdapter): boolean
54
55
  }
55
56
  }
56
57
 
58
+ // Validates that the installed overlay's runner-source stamp points to an existing
59
+ // runner directory whose binary is still present. Two failure modes are detected:
60
+ // 1. Stale stamp — runner dir moved or renamed since install (dir absent).
61
+ // 2. Stale exec target — runner dir exists but its bin/mm-harness was removed
62
+ // (e.g. the package was unlinked/purged inside an otherwise-live dir).
63
+ // Either way the delegate binary will fail to exec; treat the overlay as absent
64
+ // so auto-ensure re-installs it from the current runner.
65
+ function overlayDelegateValid(target: string, adapter: MetaMaskRecipeAdapter): boolean {
66
+ if (adapter === 'core') return true;
67
+ const pointer = path.join(recipeHarnessPath(target, adapter), 'runner', '.runner-source');
68
+ if (!fs.existsSync(pointer)) return true; // no stamp = running from local runner, valid
69
+ const runnerPath = fs.readFileSync(pointer, 'utf8').trim();
70
+ if (!Boolean(runnerPath) || !fs.existsSync(runnerPath)) return false;
71
+ // Runner dir is present; verify the binary inside still exists — the directory
72
+ // can survive a package removal while the binary inside is deleted.
73
+ return fs.existsSync(path.join(runnerPath, 'bin', 'mm-harness'));
74
+ }
75
+
57
76
  // Auto-ensure the runtime overlay (install phase). Missing + heal != off →
58
77
  // install inline, emit a first-install notice to stderr, and record the mutation.
59
78
  // heal === off disables auto-install (repro-preserving); we proceed and let the
@@ -67,7 +86,7 @@ export async function ensureOverlay(
67
86
  ): Promise<{ ok: boolean; error?: string }> {
68
87
  // core is headless — the engine path used by run/call needs no launch overlay.
69
88
  if (adapter === 'core') return { ok: true };
70
- if (overlayPresent(target, adapter)) return { ok: true };
89
+ if (overlayPresent(target, adapter) && overlayDelegateValid(target, adapter)) return { ok: true };
71
90
  if (heal === 'off') return { ok: true };
72
91
 
73
92
  const installBin = process.env.MM_HARNESS_INSTALL_BIN;
@@ -12,6 +12,8 @@ import { fileURLToPath } from 'node:url';
12
12
 
13
13
  import { Command } from 'commander';
14
14
 
15
+ import { handleUpdate, maybeNudge } from './commands/update.ts';
16
+
15
17
  // …/src → its parent is the package root that holds scripts/.
16
18
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
19
 
@@ -245,6 +247,26 @@ Example:
245
247
  mm-harness debug
246
248
  mm-harness debug --worker`,
247
249
  },
250
+ {
251
+ name: 'update',
252
+ summary: 'Update the installed mm-harness to the published latest (--check reports only; --json = {current, latest, updateAvailable}).',
253
+ example: 'mm-harness update',
254
+ helpText: `mm-harness update [flags]
255
+
256
+ Update the globally-installed mm-harness to the npm registry's latest.
257
+ Bare form upgrades (npm i -g @deeeed/metamask-harness@latest) and prints old → new;
258
+ already-current exits 0.
259
+
260
+ --check Report only — exit 0 up-to-date / exit 1 update available (no install)
261
+ --json Machine-readable { current, latest, updateAvailable }
262
+
263
+ A passive once-a-day nudge also prints one stderr line when a newer version exists.
264
+ Silence it with MM_HARNESS_NO_UPDATE_CHECK=1 (auto-off in CI).
265
+
266
+ Example:
267
+ mm-harness update
268
+ mm-harness update --check --json`,
269
+ },
248
270
  {
249
271
  name: 'fixtures',
250
272
  summary: 'Manage the canonical wallet fixture (wallet DATA only) — sync files / set the wallet.',
@@ -340,6 +362,11 @@ const HELP_GROUPS: HelpGroup[] = [
340
362
  blurb: 'install/verify/clean the per-checkout overlay (the everyday commands auto-ensure it)',
341
363
  commands: ['install', 'verify', 'cleanup'],
342
364
  },
365
+ {
366
+ title: 'MAINTAIN',
367
+ blurb: 'keep the installed harness current with the npm registry',
368
+ commands: ['update'],
369
+ },
343
370
  ];
344
371
 
345
372
  function commandMeta(name: string): { summary: string; example: string; planned: boolean } {
@@ -371,6 +398,9 @@ function groupedHelp(): string {
371
398
  lines.push('SETUP — tab-completion (zsh + bash), sourced from the bundled scripts:');
372
399
  lines.push(' mm-harness completions install # print the lines to add to ~/.zshrc / ~/.bashrc');
373
400
  lines.push('');
401
+ lines.push('DEV/PROD — the global npm install is prod; set MM_HARNESS_BIN to a dev checkout to override:');
402
+ lines.push(' MM_HARNESS_BIN=/path/to/checkout/bin/mm-harness # unset = the installed/global bin');
403
+ lines.push('');
374
404
  lines.push('See docs/MENTAL-MODEL.md (overview) and docs/CLI-SPEC.md (full contract).');
375
405
  return `${lines.join('\n')}\n`;
376
406
  }
@@ -430,6 +460,11 @@ for (const command of REAL) {
430
460
  process.stdout.write(`${command.helpText}\n`);
431
461
  process.exit(0);
432
462
  }
463
+ // `update` is self-contained registry tooling (no engine, no overlay), so it
464
+ // runs directly rather than through the dispatch in cli.ts.
465
+ if (command.name === 'update') {
466
+ process.exit(await handleUpdate(rawArgv.slice(1)));
467
+ }
433
468
  const argv = command.name === 'actions' && rawArgv.includes('--raw') ? translateActionsRaw(rawArgv) : rawArgv;
434
469
  process.exit(await delegate(argv));
435
470
  });
@@ -496,6 +531,16 @@ for (const retired of RETIRED) {
496
531
  });
497
532
  }
498
533
 
534
+ // Passive update nudge: one throttled stderr line if a newer version is published.
535
+ // Skipped for update itself (redundant) and for shell/completion tooling (whose
536
+ // stdout is consumed by the shell). stderr-only keeps every --json stdout clean.
537
+ const NUDGE_SKIP: readonly string[] = ['update', 'completions', 'completion-candidates'];
538
+ if (rawArgv.length > 0 && !NUDGE_SKIP.includes(rawArgv[0])) {
539
+ // Fully deferred: no nudge code runs before parseAsync starts. The exit hook
540
+ // registered inside maybeNudge prints the line after command output.
541
+ setImmediate(() => void maybeNudge());
542
+ }
543
+
499
544
  if (rawArgv.length === 0) {
500
545
  process.stdout.write(groupedHelp());
501
546
  process.exit(0);