@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 +22 -0
- package/README.md +27 -0
- package/adapters/mobile/open-device.sh +11 -1
- package/adapters/mobile/verify.sh +14 -7
- package/bin/mm-harness +32 -0
- package/package.json +1 -1
- package/src/adapters/mobile/prepare.ts +14 -3
- package/src/adapters/mobile/runtime-decision.ts +94 -2
- package/src/cli.ts +23 -22
- package/src/commands/debug.ts +7 -5
- package/src/commands/fixtures.ts +6 -9
- package/src/commands/launch.ts +15 -13
- package/src/commands/logs.ts +7 -10
- package/src/commands/shared.ts +18 -6
- package/src/commands/update.ts +316 -0
- package/src/harness.ts +23 -7
- package/src/heal-bounds.ts +20 -1
- package/src/mm-harness-cli.ts +45 -0
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="
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
@@ -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
|
|
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 =
|
|
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
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
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
|
-
|
|
883
|
-
|
|
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.
|
|
909
|
-
//
|
|
910
|
-
// log — safe to call before the
|
|
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
|
|
package/src/commands/debug.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/commands/fixtures.ts
CHANGED
|
@@ -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
|
|
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
|
|
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}
|
|
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;
|
package/src/commands/launch.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
}--build`,
|
|
131
|
+
'native build inputs changed since last build.',
|
|
132
|
+
`mm-harness launch ${adapter === 'mobile' ? `${mobileTarget} ` : ''}--build`,
|
|
131
133
|
);
|
|
132
134
|
}
|
|
133
135
|
|
package/src/commands/logs.ts
CHANGED
|
@@ -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
|
|
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
|
|
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})
|
|
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)})
|
|
50
|
+
`nothing running for this checkout (no ${path.basename(logFile)}).`,
|
|
51
|
+
launchHint,
|
|
55
52
|
);
|
|
56
53
|
}
|
|
57
54
|
|
package/src/commands/shared.ts
CHANGED
|
@@ -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
|
-
//
|
|
129
|
-
|
|
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(
|
|
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:
|
|
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
|
}
|
package/src/heal-bounds.ts
CHANGED
|
@@ -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;
|
package/src/mm-harness-cli.ts
CHANGED
|
@@ -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);
|