@deeeed/metamask-harness 0.2.0
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 +161 -0
- package/README.md +140 -0
- package/bin/mm-harness +99 -0
- package/docs/CHEATSHEET.md +61 -0
- package/docs/CLI-SPEC.md +915 -0
- package/docs/MENTAL-MODEL.md +295 -0
- package/docs/architecture.md +367 -0
- package/docs/extension-runtime-commands.md +60 -0
- package/docs/harness-cli.md +43 -0
- package/docs/live-adapter-contract.md +188 -0
- package/docs/package-boundaries.md +47 -0
- package/docs/perps-flow-catalog.md +235 -0
- package/docs/recipe-libraries.md +95 -0
- package/docs/runtime-file-conventions.md +36 -0
- package/library/actions/core/perps/_controller.mjs +727 -0
- package/library/actions/core/perps/assert_orders.mjs +53 -0
- package/library/actions/core/perps/assert_positions.mjs +52 -0
- package/library/actions/core/perps/close_orders.mjs +97 -0
- package/library/actions/core/perps/close_positions.mjs +118 -0
- package/library/actions/core/perps/ensure_orders.mjs +40 -0
- package/library/actions/core/perps/ensure_positions.mjs +37 -0
- package/library/actions/core/perps/place_order.mjs +201 -0
- package/library/actions/core/perps/read_account.mjs +30 -0
- package/library/actions/core/perps/read_orders.mjs +27 -0
- package/library/actions/core/perps/read_positions.mjs +27 -0
- package/library/actions/core/perps/start_state.mjs +92 -0
- package/library/actions/core/perps/teardown_state.mjs +86 -0
- package/library/actions/extension/perps/assert_orders.mjs +11 -0
- package/library/actions/extension/perps/assert_positions.mjs +11 -0
- package/library/actions/extension/perps/close_orders.mjs +8 -0
- package/library/actions/extension/perps/close_positions.mjs +8 -0
- package/library/actions/extension/perps/ensure_orders.mjs +4 -0
- package/library/actions/extension/perps/ensure_positions.mjs +4 -0
- package/library/actions/extension/perps/perps.mjs +730 -0
- package/library/actions/extension/perps/place_order.mjs +7 -0
- package/library/actions/extension/perps/read_orders.mjs +4 -0
- package/library/actions/extension/perps/read_positions.mjs +3 -0
- package/library/actions/extension/platform/cdp.mjs +541 -0
- package/library/actions/extension/ui/navigate.mjs +44 -0
- package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
- package/library/actions/extension/wallet/read_state.mjs +27 -0
- package/library/actions/extension/wallet/select_account.mjs +48 -0
- package/library/actions/extension/wallet/setup.mjs +35 -0
- package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
- package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
- package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
- package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
- package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
- package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
- package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
- package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
- package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
- package/library/actions/mobile/perps/assert_orders.mjs +11 -0
- package/library/actions/mobile/perps/assert_positions.mjs +11 -0
- package/library/actions/mobile/perps/close_orders.mjs +8 -0
- package/library/actions/mobile/perps/close_positions.mjs +8 -0
- package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
- package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
- package/library/actions/mobile/perps/perps.mjs +709 -0
- package/library/actions/mobile/perps/place_order.mjs +7 -0
- package/library/actions/mobile/perps/read_orders.mjs +4 -0
- package/library/actions/mobile/perps/read_positions.mjs +3 -0
- package/library/actions/mobile/platform/bridge.mjs +283 -0
- package/library/actions/mobile/ui/navigate.mjs +38 -0
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
- package/library/actions/mobile/wallet/home.mjs +35 -0
- package/library/actions/mobile/wallet/read_state.mjs +40 -0
- package/library/actions/mobile/wallet/select_account.mjs +48 -0
- package/library/actions/mobile/wallet/setup.mjs +220 -0
- package/library/flows/perps.flows.json +64 -0
- package/library/library.json +7 -0
- package/library/manifests/core.action-manifest.json +1282 -0
- package/library/manifests/extension.action-manifest.json +1749 -0
- package/library/manifests/mobile.action-manifest.json +1753 -0
- package/library/recipes/action-validation.extension.recipe.json +417 -0
- package/library/recipes/action-validation.mobile.recipe.json +422 -0
- package/library/recipes/order-lifecycle.core.recipe.json +78 -0
- package/library/recipes/perps-lifecycle.recipe.json +194 -0
- package/library/recipes/read-markets.core.recipe.json +38 -0
- package/library/recipes/smoke.extension.recipe.json +31 -0
- package/library/recipes/smoke.mobile.recipe.json +31 -0
- package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
- package/orchestration/compat-overlays/README.md +19 -0
- package/orchestration/compat-overlays/mobile/README.md +13 -0
- package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
- package/orchestration/core/cleanup.sh +37 -0
- package/orchestration/core/inject.sh +154 -0
- package/orchestration/doctor.mjs +72 -0
- package/orchestration/extension/cleanup.mjs +60 -0
- package/orchestration/extension/console-tail.mjs +228 -0
- package/orchestration/extension/ensure-browser.sh +416 -0
- package/orchestration/extension/ensure-ready.ts +185 -0
- package/orchestration/extension/extension-id.ts +107 -0
- package/orchestration/extension/inject.mjs +266 -0
- package/orchestration/extension/launch-browser.cjs +216 -0
- package/orchestration/extension/launch.sh +175 -0
- package/orchestration/extension/live.sh +320 -0
- package/orchestration/extension/pin-remote-flags.cjs +45 -0
- package/orchestration/extension/readiness.mjs +414 -0
- package/orchestration/extension/refresh-build.sh +190 -0
- package/orchestration/extension/runtime-decision.ts +445 -0
- package/orchestration/extension/runtime.ts +407 -0
- package/orchestration/extension/seed-fixture.sh +177 -0
- package/orchestration/extension/sidepanel-toggle.sh +291 -0
- package/orchestration/extension/snapshot-dist.sh +84 -0
- package/orchestration/extension/start-watch.sh +339 -0
- package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
- package/orchestration/lib/activate-repo-node.sh +144 -0
- package/orchestration/lib/cli-color.mjs +84 -0
- package/orchestration/lib/cli-commands.mjs +243 -0
- package/orchestration/lib/cli-home.mjs +354 -0
- package/orchestration/lib/cli-ux.sh +252 -0
- package/orchestration/lib/cli-version.mjs +123 -0
- package/orchestration/lib/ensure-runner-deps.sh +56 -0
- package/orchestration/lib/harness-path.sh +55 -0
- package/orchestration/lib/hash-helpers.sh +44 -0
- package/orchestration/lib/json-field.sh +23 -0
- package/orchestration/lib/log-tui.mjs +304 -0
- package/orchestration/lib/open-debug.mjs +317 -0
- package/orchestration/lib/path-defaults.json +4 -0
- package/orchestration/lib/progress.mjs +107 -0
- package/orchestration/lib/recipe-paths.mjs +26 -0
- package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
- package/orchestration/manifest.json +358 -0
- package/orchestration/mobile/cleanup.sh +192 -0
- package/orchestration/mobile/deps-markers.ts +21 -0
- package/orchestration/mobile/inject.sh +681 -0
- package/orchestration/mobile/launch.sh +137 -0
- package/orchestration/mobile/live.sh +125 -0
- package/orchestration/mobile/runtime-decision.ts +292 -0
- package/orchestration/porcelain/metamask-recipe +99 -0
- package/orchestration/porcelain/mm-recipe +1591 -0
- package/orchestration/porcelain/mme-recipe +1181 -0
- package/package.json +59 -0
- package/runner/extension/verify.sh +511 -0
- package/runner/mobile/verify.sh +501 -0
- package/runner/src/adapters.ts +601 -0
- package/runner/src/cli.ts +1820 -0
- package/runner/src/commands/debug.ts +44 -0
- package/runner/src/commands/fixtures.ts +99 -0
- package/runner/src/commands/launch.ts +397 -0
- package/runner/src/commands/logs.ts +60 -0
- package/runner/src/commands/shared.ts +138 -0
- package/runner/src/completions-cache.ts +86 -0
- package/runner/src/doctor.ts +203 -0
- package/runner/src/harness.ts +516 -0
- package/runner/src/heal-bounds.ts +179 -0
- package/runner/src/index.ts +6 -0
- package/runner/src/live-adapter-contract.ts +274 -0
- package/runner/src/manifest.ts +47 -0
- package/runner/src/mm-harness-cli.ts +488 -0
- package/runner/src/paths.ts +198 -0
- package/runner/src/recording-target.ts +147 -0
- package/runner/src/run-recording.ts +329 -0
- package/runner/src/runner.ts +108 -0
- package/runner/src/types.ts +57 -0
- package/scripts/completions.sh +125 -0
- package/scripts/install-completions.sh +62 -0
|
@@ -0,0 +1,1820 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { createDoctorReport, renderRuntimeContext } from './doctor.ts';
|
|
8
|
+
import {
|
|
9
|
+
invalidateCompletionCache,
|
|
10
|
+
readFreshCandidates,
|
|
11
|
+
writeCompletionCandidates,
|
|
12
|
+
} from './completions-cache.ts';
|
|
13
|
+
import { detectAdapter, handleHarness } from './harness.ts';
|
|
14
|
+
import { handleLaunch } from './commands/launch.ts';
|
|
15
|
+
import { handleLogs } from './commands/logs.ts';
|
|
16
|
+
import { handleDebug } from './commands/debug.ts';
|
|
17
|
+
import { handleFixtures } from './commands/fixtures.ts';
|
|
18
|
+
import {
|
|
19
|
+
ensureOverlay,
|
|
20
|
+
newHealState,
|
|
21
|
+
parseHeal,
|
|
22
|
+
recipeRunning,
|
|
23
|
+
checkHealBounds,
|
|
24
|
+
} from './heal-bounds.ts';
|
|
25
|
+
import type { HealPolicy, HealState, HealBoundViolation } from './heal-bounds.ts';
|
|
26
|
+
import { ensureExtensionReady } from '../../orchestration/extension/ensure-ready.ts';
|
|
27
|
+
import { resolveExtensionId } from '../../orchestration/extension/extension-id.ts';
|
|
28
|
+
import { decideExtensionReadiness } from '../../orchestration/extension/runtime-decision.ts';
|
|
29
|
+
import { decideMobileReadiness } from '../../orchestration/mobile/runtime-decision.ts';
|
|
30
|
+
// NOTE: extension-runtime.ts loads the recipe harness at module scope, so it
|
|
31
|
+
// is imported LAZILY (dynamic import) only inside the handlers that drive a live
|
|
32
|
+
// runtime. Static-import it here and every command — manifest, doctor,
|
|
33
|
+
// runtime-decision (no --cdp-port) — would fail to load on a checkout without
|
|
34
|
+
// local harness packages built. Keep this lazy.
|
|
35
|
+
import { loadActionManifest, validateManifest } from './manifest.ts';
|
|
36
|
+
import {
|
|
37
|
+
assertAdapter,
|
|
38
|
+
importRecipeHarness,
|
|
39
|
+
importRecipeHarnessCli,
|
|
40
|
+
importRecipeProtocol,
|
|
41
|
+
manifestPath,
|
|
42
|
+
recipeHarnessPath,
|
|
43
|
+
recipePath,
|
|
44
|
+
runnerDir,
|
|
45
|
+
walletFixturePath,
|
|
46
|
+
} from './paths.ts';
|
|
47
|
+
import { captureHelperSupportsRecordSessionSnapshots } from './recording-target.ts';
|
|
48
|
+
import { startRecipeRecording, stopRecipeRecording } from './run-recording.ts';
|
|
49
|
+
// runner.ts → adapters.ts → library/actions/extension/platform/cdp.mjs does a
|
|
50
|
+
// top-level harness import, so it is imported LAZILY inside
|
|
51
|
+
// runRecipe only. Keeping it static would load the recipe harness for every
|
|
52
|
+
// command (manifest, doctor, runtime-decision), defeating their independence.
|
|
53
|
+
import type { RecipeRunResult } from '@farmslot/recipe-harness';
|
|
54
|
+
import type {
|
|
55
|
+
RecipeActionManifestDocument,
|
|
56
|
+
RecipeValidationFinding,
|
|
57
|
+
RecipeValidationResult,
|
|
58
|
+
} from '@farmslot/protocol';
|
|
59
|
+
import type { MetaMaskRecipeAdapter } from './types.ts';
|
|
60
|
+
import { usageOut } from './commands/shared.ts';
|
|
61
|
+
|
|
62
|
+
type CliOptionValue = string | boolean;
|
|
63
|
+
type CliOptions = Record<string, CliOptionValue>;
|
|
64
|
+
|
|
65
|
+
interface ParsedArgs {
|
|
66
|
+
positional: string[];
|
|
67
|
+
options: CliOptions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RuntimeOptions {
|
|
71
|
+
cdpPort?: string;
|
|
72
|
+
watcherPort?: string;
|
|
73
|
+
launchExistingDist?: boolean;
|
|
74
|
+
startWatch?: boolean;
|
|
75
|
+
skipExtensionRuntimePrepare?: boolean;
|
|
76
|
+
slot?: string;
|
|
77
|
+
validationRuntimeDir?: string;
|
|
78
|
+
recordVideo?: false | 'full-run';
|
|
79
|
+
librarySources?: MetaMaskLibrarySource[];
|
|
80
|
+
// --json mode: stdout is the machine contract, so the engine logger (e.g. its
|
|
81
|
+
// "Recipe libraries: …" resolution line) must be kept off stdout.
|
|
82
|
+
stdoutIsMachineContract?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface MetaMaskLibrarySource {
|
|
86
|
+
name?: string;
|
|
87
|
+
root: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The installed @farmslot/recipe-harness may predate recipe libraries; probe
|
|
91
|
+
// for the API instead of assuming it so every other command keeps working.
|
|
92
|
+
interface RecipeLibraryCapableHarness {
|
|
93
|
+
resolveRecipeLibrarySources?: (options?: {
|
|
94
|
+
cliEntries?: string[];
|
|
95
|
+
}) => Promise<MetaMaskLibrarySource[]>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const COMMANDS: Record<string, (args: ParsedArgs) => Promise<number>> = {
|
|
99
|
+
manifest: handleManifest,
|
|
100
|
+
actions: handleActions,
|
|
101
|
+
doctor: handleDoctor,
|
|
102
|
+
'runtime-health': handleRuntimeHealth,
|
|
103
|
+
'runtime-decision': handleRuntimeDecision,
|
|
104
|
+
'runtime-launch': handleRuntimeLaunch,
|
|
105
|
+
'resolve-extension': handleResolveExtension,
|
|
106
|
+
'ensure-ready': handleEnsureReady,
|
|
107
|
+
run: handleRun,
|
|
108
|
+
'self-test': handleSelfTest,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Top-level runtime-overlay commands (the tool installs a runtime overlay).
|
|
112
|
+
// Top-level overlay commands route to handleHarness; the `harness <sub>`
|
|
113
|
+
// subcommand form has been removed from the CLI surface.
|
|
114
|
+
const OVERLAY_COMMANDS: readonly string[] = ['install', 'verify', 'cleanup', 'live'];
|
|
115
|
+
|
|
116
|
+
function usage() {
|
|
117
|
+
// Help is organized by the harness mental model: mm-harness IS the tool; a
|
|
118
|
+
// recipe is one thing it runs; the "runtime overlay" is what it installs into a
|
|
119
|
+
// checkout. End state = one bin (mm-harness); help presents only that surface.
|
|
120
|
+
// Every command ships one copy-pasteable example (cold-start discoverability;
|
|
121
|
+
// zero-flag happy path where possible).
|
|
122
|
+
console.error(`mm-harness — the MetaMask recipe harness: launch the app, prove behavior, manage the runtime overlay.
|
|
123
|
+
Run it from inside a MetaMask checkout; the platform (mobile | extension | core) is auto-detected.
|
|
124
|
+
Grammar: mm-harness <command> [target] [flags] (target is a positional: ios | android | extension; flags add agent depth)
|
|
125
|
+
|
|
126
|
+
DAILY LOOP — what a teammate runs many times a day:
|
|
127
|
+
launch Launch the app incl. Metro/build, surface auto-detected. Mobile: ios|android required.
|
|
128
|
+
mm-harness launch ios # quick relaunch (add --build | --verify)
|
|
129
|
+
logs Tail Metro/webpack + app logs.
|
|
130
|
+
mm-harness logs
|
|
131
|
+
debug Open the debug console (extension DevTools / mobile RN).
|
|
132
|
+
mm-harness debug
|
|
133
|
+
fixtures Sync fixture files + set up the wallet (SRP/password).
|
|
134
|
+
mm-harness fixtures sync # or: mm-harness fixtures set
|
|
135
|
+
|
|
136
|
+
PROVE — run recipes and inspect capabilities:
|
|
137
|
+
run Run a recipe and write evidence (summary/trace/artifacts).
|
|
138
|
+
mm-harness run recipe.json
|
|
139
|
+
flows List/promote recipe library flows (personal/team win over canonical).
|
|
140
|
+
mm-harness flows list
|
|
141
|
+
doctor Readiness check for a checkout (no app launch).
|
|
142
|
+
mm-harness doctor
|
|
143
|
+
actions Describe the actions a manifest declares.
|
|
144
|
+
mm-harness actions --adapter mobile
|
|
145
|
+
manifest Print/validate the action manifest for an adapter.
|
|
146
|
+
mm-harness manifest --adapter extension --json
|
|
147
|
+
|
|
148
|
+
RUNTIME OVERLAY — install/verify/clean the per-checkout runtime overlay:
|
|
149
|
+
install Install the runtime overlay into the checkout.
|
|
150
|
+
mm-harness install # inside a checkout, auto-detected
|
|
151
|
+
verify Check the overlay/runtime is present and healthy (no launch).
|
|
152
|
+
mm-harness verify
|
|
153
|
+
live Launch/reuse the app, then verify live control end-to-end.
|
|
154
|
+
mm-harness live
|
|
155
|
+
cleanup Remove the installed overlay and restore the checkout.
|
|
156
|
+
mm-harness cleanup
|
|
157
|
+
# Open sub-question (Arthur): keep these top-level (mm-harness install) or group
|
|
158
|
+
# them under an \`overlay\` command (mm-harness overlay install)? Top-level for now.
|
|
159
|
+
|
|
160
|
+
ADVANCED — internal runtime probes (rarely typed by hand):
|
|
161
|
+
runtime-health runtime-decision runtime-launch
|
|
162
|
+
resolve-extension ensure-ready self-test
|
|
163
|
+
(e.g. mm-harness runtime-decision --adapter extension --target <repo> --json)
|
|
164
|
+
|
|
165
|
+
ONE bin: mm-harness is the only command. No per-platform binaries — platform is
|
|
166
|
+
auto-detected, the positional target forces it (mm-harness launch ios), and
|
|
167
|
+
platform-specific needs are FLAGS on the same command (e.g. --sidebar, --full-build).
|
|
168
|
+
Human happy path = the bare command; agents add depth via flags (--json, --target, ports).
|
|
169
|
+
See docs/MENTAL-MODEL.md (overview) and docs/CLI-SPEC.md (full contract).
|
|
170
|
+
`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseArgs(argv: string[], command?: string): ParsedArgs {
|
|
174
|
+
const positional: string[] = [];
|
|
175
|
+
const options: CliOptions = {};
|
|
176
|
+
const booleanOptions = new Set(['json', 'launchExistingDist', 'startWatch', 'record', 'plan', 'raw', 'fix']);
|
|
177
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
178
|
+
const arg = argv[i];
|
|
179
|
+
if (!arg.startsWith('--')) {
|
|
180
|
+
positional.push(arg);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const body = arg.slice(2);
|
|
184
|
+
const equalsIndex = body.indexOf('=');
|
|
185
|
+
const rawKey = equalsIndex === -1 ? body : body.slice(0, equalsIndex);
|
|
186
|
+
const inlineValue = equalsIndex === -1 ? undefined : body.slice(equalsIndex + 1);
|
|
187
|
+
const key = normalizeOptionKey(rawKey);
|
|
188
|
+
if (key === 'recordVideo') {
|
|
189
|
+
options.recordVideo = parseRecordVideoMode(inlineValue);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (key === 'recordBaseline') {
|
|
193
|
+
options.record = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (key === 'record') {
|
|
197
|
+
if (command === 'runtime-decision') {
|
|
198
|
+
options.record = true;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
options.recordVideo = 'full-run';
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (booleanOptions.has(key)) {
|
|
205
|
+
options[key] = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (inlineValue !== undefined) {
|
|
209
|
+
options[key] = inlineValue;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (i + 1 >= argv.length) throw usageError(`Missing value for ${arg}`);
|
|
213
|
+
options[key] = argv[i + 1];
|
|
214
|
+
i += 1;
|
|
215
|
+
}
|
|
216
|
+
return { positional, options };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseRecordVideoMode(value: string | undefined): false | 'full-run' {
|
|
220
|
+
if (value === undefined || value === '' || value === 'true') return 'full-run';
|
|
221
|
+
if (value === 'off' || value === 'false') return false;
|
|
222
|
+
if (value === 'proof-window' || value === 'proof_window') {
|
|
223
|
+
throw usageError(
|
|
224
|
+
'--record-video=proof-window is not supported yet; use --record-video=full-run.',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (value !== 'full-run') {
|
|
228
|
+
throw usageError('--record-video must be full-run or off.');
|
|
229
|
+
}
|
|
230
|
+
return 'full-run';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeOptionKey(key: string): string {
|
|
234
|
+
return key.replace(/-([a-z])/gu, (_, character: string) => character.toUpperCase());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function optionString(options: CliOptions, key: string): string | undefined {
|
|
238
|
+
const value = options[key];
|
|
239
|
+
if (value === undefined) return undefined;
|
|
240
|
+
if (typeof value !== 'string') throw usageError(`--${key} requires a value.`);
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function optionFlag(options: CliOptions, key: string): boolean {
|
|
245
|
+
const value = options[key];
|
|
246
|
+
return value === true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function requiredOption(options: CliOptions, key: string, message: string): string {
|
|
250
|
+
const value = optionString(options, key);
|
|
251
|
+
if (!value) throw usageError(message);
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function adapterOption(options: CliOptions): MetaMaskRecipeAdapter {
|
|
256
|
+
const adapter = optionString(options, 'adapter');
|
|
257
|
+
try {
|
|
258
|
+
assertAdapter(adapter);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
throw usageError(error instanceof Error ? error.message : String(error));
|
|
261
|
+
}
|
|
262
|
+
return adapter;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function targetPath(options: CliOptions): string {
|
|
266
|
+
return path.resolve(optionString(options, 'target') ?? optionString(options, 'projectRoot') ?? process.cwd());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function actionManifestPathOption(options: CliOptions, adapter: MetaMaskRecipeAdapter): string {
|
|
270
|
+
const configured = optionString(options, 'actionManifest');
|
|
271
|
+
return configured ? path.resolve(configured) : manifestPath(adapter);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function runRecipe(
|
|
275
|
+
adapter: MetaMaskRecipeAdapter,
|
|
276
|
+
recipe: string,
|
|
277
|
+
artifactsDir: string,
|
|
278
|
+
projectRoot: string,
|
|
279
|
+
actionManifestPath?: string,
|
|
280
|
+
runtimeOptions: RuntimeOptions = {},
|
|
281
|
+
): Promise<RecipeRunResult> {
|
|
282
|
+
const previousCdpPort = process.env.CDP_PORT;
|
|
283
|
+
const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
|
|
284
|
+
const previousWatcherPort = process.env.WATCHER_PORT;
|
|
285
|
+
const previousMetroPort = process.env.METRO_PORT;
|
|
286
|
+
const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
|
|
287
|
+
if (runtimeOptions.cdpPort) {
|
|
288
|
+
process.env.CDP_PORT = runtimeOptions.cdpPort;
|
|
289
|
+
process.env.RECIPE_CDP_PORT = runtimeOptions.cdpPort;
|
|
290
|
+
}
|
|
291
|
+
if (runtimeOptions.watcherPort) {
|
|
292
|
+
process.env.WATCHER_PORT = runtimeOptions.watcherPort;
|
|
293
|
+
process.env.METRO_PORT = runtimeOptions.watcherPort;
|
|
294
|
+
}
|
|
295
|
+
if (runtimeOptions.launchExistingDist) {
|
|
296
|
+
process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = '1';
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions);
|
|
300
|
+
const absoluteArtifactsDir = path.resolve(artifactsDir);
|
|
301
|
+
const recordVideo = runtimeOptions.recordVideo ?? false;
|
|
302
|
+
const useFramedExtensionRecording =
|
|
303
|
+
adapter === 'extension' &&
|
|
304
|
+
recordVideo === 'full-run' &&
|
|
305
|
+
captureHelperSupportsRecordSessionSnapshots(projectRoot);
|
|
306
|
+
const recording = useFramedExtensionRecording
|
|
307
|
+
? await startRecipeRecording(adapter, projectRoot, absoluteArtifactsDir, {
|
|
308
|
+
record: true,
|
|
309
|
+
cdpPort: runtimeOptions.cdpPort,
|
|
310
|
+
})
|
|
311
|
+
: undefined;
|
|
312
|
+
try {
|
|
313
|
+
const manifest = loadActionManifest(adapter, actionManifestPath);
|
|
314
|
+
await validateManifest(manifest);
|
|
315
|
+
const { createMetaMaskRunner } = await import('./runner.ts');
|
|
316
|
+
const runner = await createMetaMaskRunner(adapter, manifest, {
|
|
317
|
+
quietStdout: runtimeOptions.stdoutIsMachineContract === true,
|
|
318
|
+
});
|
|
319
|
+
// Intersection keeps this compiling against harness versions that
|
|
320
|
+
// predate recipe libraries; those ignore the extra key at runtime.
|
|
321
|
+
const runRequest: Parameters<typeof runner.run>[0] & {
|
|
322
|
+
librarySources?: MetaMaskLibrarySource[];
|
|
323
|
+
} = {
|
|
324
|
+
recipePath: path.resolve(recipe),
|
|
325
|
+
artifactsDir: absoluteArtifactsDir,
|
|
326
|
+
projectRoot,
|
|
327
|
+
env: recipeRunEnv(adapter, runtimeOptions),
|
|
328
|
+
recordVideo: useFramedExtensionRecording ? false : recordVideo,
|
|
329
|
+
...(runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}),
|
|
330
|
+
};
|
|
331
|
+
const result = await runner.run(runRequest);
|
|
332
|
+
await stopRecipeRecording(recording, result);
|
|
333
|
+
return result;
|
|
334
|
+
} finally {
|
|
335
|
+
await stopRecipeRecording(recording);
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
restoreEnv('CDP_PORT', previousCdpPort);
|
|
339
|
+
restoreEnv('RECIPE_CDP_PORT', previousRecipeCdpPort);
|
|
340
|
+
restoreEnv('WATCHER_PORT', previousWatcherPort);
|
|
341
|
+
restoreEnv('METRO_PORT', previousMetroPort);
|
|
342
|
+
restoreEnv('METAMASK_RECIPE_EXTENSION_AUTOLAUNCH', previousExtensionAutolaunch);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function recipeRunEnv(
|
|
347
|
+
adapter: MetaMaskRecipeAdapter,
|
|
348
|
+
runtimeOptions: RuntimeOptions = {},
|
|
349
|
+
): Record<string, string | undefined> {
|
|
350
|
+
const base: Record<string, string | undefined> = {
|
|
351
|
+
CDP_PORT: runtimeOptions.cdpPort ?? process.env.CDP_PORT,
|
|
352
|
+
RECIPE_CDP_PORT: runtimeOptions.cdpPort ?? process.env.RECIPE_CDP_PORT,
|
|
353
|
+
FARMSLOT_SLOT_ID: runtimeOptions.slot ?? process.env.FARMSLOT_SLOT_ID,
|
|
354
|
+
SLOT_ID: runtimeOptions.slot ?? process.env.SLOT_ID,
|
|
355
|
+
PLATFORM: process.env.PLATFORM,
|
|
356
|
+
};
|
|
357
|
+
if (adapter !== 'mobile') return base;
|
|
358
|
+
return {
|
|
359
|
+
...base,
|
|
360
|
+
WATCHER_PORT: process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
|
|
361
|
+
METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
|
|
362
|
+
IOS_SIMULATOR: process.env.IOS_SIMULATOR,
|
|
363
|
+
ANDROID_DEVICE: process.env.ANDROID_DEVICE,
|
|
364
|
+
ADB_SERIAL: process.env.ADB_SERIAL,
|
|
365
|
+
ANDROID_SERIAL: process.env.ANDROID_SERIAL,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function prepareRuntimeIfNeeded(
|
|
370
|
+
adapter: MetaMaskRecipeAdapter,
|
|
371
|
+
projectRoot: string,
|
|
372
|
+
runtimeOptions: RuntimeOptions,
|
|
373
|
+
): Promise<void> {
|
|
374
|
+
if (adapter !== 'extension' || runtimeOptions.skipExtensionRuntimePrepare === true) return;
|
|
375
|
+
const { prepareExtensionRuntime } = await import('../../orchestration/extension/runtime.ts');
|
|
376
|
+
await prepareExtensionRuntime({
|
|
377
|
+
projectRoot,
|
|
378
|
+
cdpPort: runtimeOptions.cdpPort,
|
|
379
|
+
slot: runtimeOptions.slot,
|
|
380
|
+
launchExistingDist: runtimeOptions.launchExistingDist === true,
|
|
381
|
+
validationRuntimeDir: runtimeOptions.validationRuntimeDir,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function restoreEnv(key: string, value: string | undefined) {
|
|
386
|
+
if (value === undefined) delete process.env[key];
|
|
387
|
+
else process.env[key] = value;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function runSelfTest(options: CliOptions) {
|
|
391
|
+
const root = optionString(options, 'artifactsDir')
|
|
392
|
+
? path.resolve(requiredOption(options, 'artifactsDir', 'self-test artifacts dir missing.'))
|
|
393
|
+
: await mkdtemp(path.join(os.tmpdir(), 'metamask-recipe-runner-'));
|
|
394
|
+
const previousAutoHud = process.env.METAMASK_RECIPE_AUTO_HUD;
|
|
395
|
+
const runs = [];
|
|
396
|
+
try {
|
|
397
|
+
// Self-test is a package wiring check, not a live-device proof. Disable the
|
|
398
|
+
// automatic HUD and extension launch so it remains safe in fresh checkouts.
|
|
399
|
+
process.env.METAMASK_RECIPE_AUTO_HUD = '0';
|
|
400
|
+
for (const adapter of ['mobile', 'extension'] as const) {
|
|
401
|
+
const manifest = loadActionManifest(adapter);
|
|
402
|
+
const manifestValidation = await validateManifest(manifest);
|
|
403
|
+
const smokeRecipe = recipePath(
|
|
404
|
+
adapter === 'mobile' ? 'smoke.mobile.recipe.json' : 'smoke.extension.recipe.json',
|
|
405
|
+
);
|
|
406
|
+
const artifactsDir = path.join(root, adapter);
|
|
407
|
+
const result = await runRecipe(adapter, smokeRecipe, artifactsDir, runnerDir, undefined, {
|
|
408
|
+
skipExtensionRuntimePrepare: true,
|
|
409
|
+
});
|
|
410
|
+
runs.push({ adapter, manifestValidation: manifestValidation.summary, artifactsDir, result });
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
restoreEnv('METAMASK_RECIPE_AUTO_HUD', previousAutoHud);
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
status: runs.every((run) => run.result.status === 'pass') ? 'pass' : 'fail',
|
|
417
|
+
artifactsDir: root,
|
|
418
|
+
runs,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function handleManifest({ options }: ParsedArgs): Promise<number> {
|
|
423
|
+
const adapter = adapterOption(options);
|
|
424
|
+
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
425
|
+
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
426
|
+
await validateManifest(manifest);
|
|
427
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(manifest, null, 2));
|
|
428
|
+
else console.log(actionManifestPath);
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function handleActions({ options }: ParsedArgs): Promise<number> {
|
|
433
|
+
const adapter = adapterOption(options);
|
|
434
|
+
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
435
|
+
await validateManifest(manifest);
|
|
436
|
+
const action = optionString(options, 'action');
|
|
437
|
+
const actions = describeManifestActions(manifest, action);
|
|
438
|
+
if (optionFlag(options, 'json')) {
|
|
439
|
+
console.log(JSON.stringify({ adapter, actions }, null, 2));
|
|
440
|
+
} else {
|
|
441
|
+
for (const entry of actions) {
|
|
442
|
+
const fields = entry.fields.length ? ` fields=${entry.fields.join(',')}` : '';
|
|
443
|
+
console.log(`${entry.name} (${entry.kind})${fields}${entry.description ? ` — ${entry.description}` : ''}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function handleDoctor({ options }: ParsedArgs): Promise<number> {
|
|
450
|
+
const target = targetPath(options);
|
|
451
|
+
// Share the exact detect-from-target logic the overlay commands (verify/install/
|
|
452
|
+
// cleanup) use: when --adapter/--platform is omitted, auto-detect from the
|
|
453
|
+
// target (defaults to cwd) instead of hard-failing on a missing flag.
|
|
454
|
+
const json = optionFlag(options, 'json');
|
|
455
|
+
const explicitAdapter = optionString(options, 'adapter') ?? optionString(options, 'platform');
|
|
456
|
+
const adapter = explicitAdapter ?? detectAdapter(target);
|
|
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
|
+
);
|
|
463
|
+
}
|
|
464
|
+
assertAdapter(adapter);
|
|
465
|
+
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
466
|
+
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
467
|
+
const manifestValidation = await validateManifest(manifest);
|
|
468
|
+
|
|
469
|
+
// --fix runs the shared healing steps WITHOUT launching the app (overlay
|
|
470
|
+
// auto-ensure + the same bounds run/launch use; never a fixture reseed) and
|
|
471
|
+
// reports fixed[]/failed[]. Without --fix, doctor is pure read-only.
|
|
472
|
+
if (optionFlag(options, 'fix')) {
|
|
473
|
+
const { fixed, failed } = await runDoctorFix(adapter, target, manifestValidation, json);
|
|
474
|
+
// Re-read the report AFTER repairs so its checks reflect the healed state.
|
|
475
|
+
const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
|
|
476
|
+
if (json) console.log(JSON.stringify({ ...result, fixed, failed }, null, 2));
|
|
477
|
+
else console.log(`${result.status} ${adapter} ${result.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(',')}] failed=[${failed.join(',')}]`);
|
|
478
|
+
return failed.length === 0 ? 0 : 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
|
|
482
|
+
if (json) console.log(JSON.stringify(result, null, 2));
|
|
483
|
+
else {
|
|
484
|
+
console.log(`${result.status} ${adapter} ${result.compatibilityMode} manifest=${actionManifestPath}`);
|
|
485
|
+
console.log(renderRuntimeContext(result.runtimeContext));
|
|
486
|
+
}
|
|
487
|
+
return result.status === 'pass' ? 0 : 1;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// `doctor --fix` repair path: the shared self-healing internals with no app launch.
|
|
491
|
+
// Auto-ensures the runtime overlay if missing (infra-only bound; core is headless
|
|
492
|
+
// and no-ops); a manifest that fails validation is not overlay-healable and is
|
|
493
|
+
// reported as failed. Healing NEVER touches fixtures. Returns the codes actually
|
|
494
|
+
// repaired (fixed[]) and the ones it could not (failed[]).
|
|
495
|
+
async function runDoctorFix(
|
|
496
|
+
adapter: MetaMaskRecipeAdapter,
|
|
497
|
+
target: string,
|
|
498
|
+
manifestValidation: RecipeValidationResult,
|
|
499
|
+
json: boolean,
|
|
500
|
+
): Promise<{ fixed: string[]; failed: string[] }> {
|
|
501
|
+
const fixed: string[] = [];
|
|
502
|
+
const failed: string[] = [];
|
|
503
|
+
|
|
504
|
+
// Refuse to repair while a recipe is running — mid-run mutation corrupts state.
|
|
505
|
+
if (recipeRunning(target)) {
|
|
506
|
+
failed.push('recipe-running');
|
|
507
|
+
return { fixed, failed };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Manifest validity is a read-only check: a broken manifest is not something the
|
|
511
|
+
// overlay install can heal, so surface it as a failed code rather than mutating.
|
|
512
|
+
if (Number(manifestValidation.summary?.errors ?? 0) > 0) failed.push('manifest');
|
|
513
|
+
|
|
514
|
+
// Overlay auto-ensure — the one positively-identified infra recovery available
|
|
515
|
+
// without a launch. ensureOverlay records a mutation only when it actually
|
|
516
|
+
// installs a missing overlay, so an already-present overlay is not reported.
|
|
517
|
+
const state = newHealState();
|
|
518
|
+
const ensured = await ensureOverlay(adapter, target, 'infra-only', state, json);
|
|
519
|
+
if (!ensured.ok) failed.push('overlay');
|
|
520
|
+
else if (state.mutations.length > 0) fixed.push('overlay');
|
|
521
|
+
|
|
522
|
+
return { fixed, failed };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function describeManifestActions(
|
|
526
|
+
manifest: unknown,
|
|
527
|
+
filterAction?: string,
|
|
528
|
+
): Array<{
|
|
529
|
+
name: string;
|
|
530
|
+
kind: 'official' | 'custom';
|
|
531
|
+
description: string;
|
|
532
|
+
fields: string[];
|
|
533
|
+
schema?: unknown;
|
|
534
|
+
examples?: unknown;
|
|
535
|
+
}> {
|
|
536
|
+
const manifestRecord = isRecord(manifest) ? manifest : {};
|
|
537
|
+
const metadata = isRecord(manifestRecord.action_metadata) ? manifestRecord.action_metadata : {};
|
|
538
|
+
const official = Array.isArray(manifestRecord.supported_official_actions)
|
|
539
|
+
? manifestRecord.supported_official_actions.filter((value): value is string => typeof value === 'string')
|
|
540
|
+
: [];
|
|
541
|
+
const custom = Array.isArray(manifestRecord.custom_actions)
|
|
542
|
+
? manifestRecord.custom_actions.flatMap((entry) => {
|
|
543
|
+
if (typeof entry === 'string') return [{ name: entry, metadata: metadata[entry] }];
|
|
544
|
+
if (isRecord(entry) && typeof entry.name === 'string') {
|
|
545
|
+
const entryMetadata = { ...entry };
|
|
546
|
+
const metadataOverride = metadata[entry.name];
|
|
547
|
+
if (isRecord(metadataOverride)) Object.assign(entryMetadata, metadataOverride);
|
|
548
|
+
return [{ name: entry.name, metadata: entryMetadata }];
|
|
549
|
+
}
|
|
550
|
+
return [];
|
|
551
|
+
})
|
|
552
|
+
: [];
|
|
553
|
+
const entries = [
|
|
554
|
+
...official.map((name) => describeManifestAction(name, 'official' as const, metadata[name])),
|
|
555
|
+
...custom.map((entry) => describeManifestAction(entry.name, 'custom' as const, entry.metadata)),
|
|
556
|
+
].filter((entry) => !filterAction || entry.name === filterAction);
|
|
557
|
+
if (filterAction && entries.length === 0) throw new Error(`Action not found in manifest: ${filterAction}`);
|
|
558
|
+
return entries;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function describeManifestAction(
|
|
562
|
+
name: string,
|
|
563
|
+
kind: 'official' | 'custom',
|
|
564
|
+
metadata: unknown,
|
|
565
|
+
): {
|
|
566
|
+
name: string;
|
|
567
|
+
kind: 'official' | 'custom';
|
|
568
|
+
description: string;
|
|
569
|
+
fields: string[];
|
|
570
|
+
schema?: unknown;
|
|
571
|
+
examples?: unknown;
|
|
572
|
+
} {
|
|
573
|
+
const record = isRecord(metadata) ? metadata : {};
|
|
574
|
+
const schema = record.schema;
|
|
575
|
+
const schemaRecord = isRecord(schema) ? schema : {};
|
|
576
|
+
const properties = isRecord(schemaRecord.properties) ? Object.keys(schemaRecord.properties).sort() : [];
|
|
577
|
+
return {
|
|
578
|
+
name,
|
|
579
|
+
kind,
|
|
580
|
+
description: typeof record.description === 'string' ? record.description : '',
|
|
581
|
+
fields: properties,
|
|
582
|
+
schema,
|
|
583
|
+
examples: record.examples,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
588
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function handleRuntimeHealth({ options }: ParsedArgs): Promise<number> {
|
|
592
|
+
const adapter = adapterOption(options);
|
|
593
|
+
if (adapter !== 'extension') throw new Error('runtime-health currently applies to the extension adapter.');
|
|
594
|
+
const target = targetPath(options);
|
|
595
|
+
const cdpPort = parsePort(
|
|
596
|
+
optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
|
|
597
|
+
'runtime-health requires --cdp-port <port>.',
|
|
598
|
+
);
|
|
599
|
+
const { checkExtensionRuntimeHealth, formatHealthFailure } = await import('../../orchestration/extension/runtime.ts');
|
|
600
|
+
const report = await checkExtensionRuntimeHealth(target, cdpPort);
|
|
601
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
|
|
602
|
+
else if (report.status === 'PASS') console.log(`PASS extension runtime cdp=${cdpPort} target=${report.targetUrl}`);
|
|
603
|
+
else console.error(formatHealthFailure(report, target));
|
|
604
|
+
return report.status === 'PASS' ? 0 : 1;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
async function handleRuntimeLaunch({ options }: ParsedArgs): Promise<number> {
|
|
609
|
+
const adapter = adapterOption(options);
|
|
610
|
+
const target = targetPath(options);
|
|
611
|
+
if (adapter !== 'extension') throw new Error('runtime-launch currently applies to the extension adapter.');
|
|
612
|
+
const cdpPort = parsePort(
|
|
613
|
+
optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
|
|
614
|
+
'runtime-launch requires --cdp-port <port>.',
|
|
615
|
+
);
|
|
616
|
+
const chromeUserDataDir = optionString(options, 'chromeUserDataDir');
|
|
617
|
+
const artifactsDir = path.resolve(
|
|
618
|
+
optionString(options, 'artifactsDir') ??
|
|
619
|
+
recipeHarnessPath(target, 'extension', 'runtime-launch', new Date().toISOString().replace(/[:.]/gu, '')),
|
|
620
|
+
);
|
|
621
|
+
const startWatch = optionFlag(options, 'startWatch');
|
|
622
|
+
const liveScript = recipeHarnessPath(target, 'extension', 'scripts', 'live.sh');
|
|
623
|
+
const command = [
|
|
624
|
+
'bash',
|
|
625
|
+
liveScript,
|
|
626
|
+
'--target',
|
|
627
|
+
target,
|
|
628
|
+
'--cdp-port',
|
|
629
|
+
String(cdpPort),
|
|
630
|
+
startWatch ? '--start-watch' : '--launch-existing-dist',
|
|
631
|
+
'--artifacts-dir',
|
|
632
|
+
artifactsDir,
|
|
633
|
+
];
|
|
634
|
+
if (chromeUserDataDir) command.push('--chrome-user-data-dir', chromeUserDataDir);
|
|
635
|
+
// Optional A/B feature-flag pinning. Passed straight through to live.sh, which
|
|
636
|
+
// patches the ephemeral runtime-dist snapshot manifest before Chrome loads it.
|
|
637
|
+
// Omitting it leaves the launch byte-identical to the previous behavior.
|
|
638
|
+
const remoteFlag = optionString(options, 'remoteFlag');
|
|
639
|
+
if (remoteFlag) command.push('--remote-flag', remoteFlag);
|
|
640
|
+
|
|
641
|
+
if (!fs.existsSync(liveScript)) {
|
|
642
|
+
const report = runtimeLaunchReport('fail', {
|
|
643
|
+
adapter,
|
|
644
|
+
target,
|
|
645
|
+
cdpPort,
|
|
646
|
+
artifactsDir,
|
|
647
|
+
reason: 'harness_live_script_missing',
|
|
648
|
+
fix: `Run metamask-recipe extension prepare --target ${shellQuote(target)}, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir)}`,
|
|
649
|
+
command,
|
|
650
|
+
});
|
|
651
|
+
printRuntimeLaunchReport(report, optionFlag(options, 'json'));
|
|
652
|
+
return 1;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
656
|
+
const jsonMode = optionFlag(options, 'json');
|
|
657
|
+
if (!jsonMode) {
|
|
658
|
+
console.error(`recipe: runtime-launch starting (cdp=${cdpPort}${startWatch ? ', clean webpack build' : ', existing dist'})`);
|
|
659
|
+
} else {
|
|
660
|
+
console.error(JSON.stringify({
|
|
661
|
+
schemaVersion: 1,
|
|
662
|
+
type: 'progress',
|
|
663
|
+
command: 'rebuild',
|
|
664
|
+
phase: 'runtime-launch',
|
|
665
|
+
message: startWatch ? 'clean webpack build starting' : 'launching existing dist',
|
|
666
|
+
cdpPort,
|
|
667
|
+
}));
|
|
668
|
+
}
|
|
669
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
670
|
+
cwd: target,
|
|
671
|
+
encoding: 'utf8',
|
|
672
|
+
maxBuffer: 5 * 1024 * 1024,
|
|
673
|
+
// Human launches stream webpack/rsync/verify output live; --json keeps
|
|
674
|
+
// machine-readable capture for wrappers that pipe the final report.
|
|
675
|
+
stdio: jsonMode ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
676
|
+
});
|
|
677
|
+
const summaryPath = path.join(artifactsDir, 'summary.json');
|
|
678
|
+
const summary = readJsonIfExists(summaryPath);
|
|
679
|
+
|
|
680
|
+
if (result.status === 0 && isRecord(summary) && summary.status === 'pass') {
|
|
681
|
+
const report = runtimeLaunchReport('pass', {
|
|
682
|
+
adapter,
|
|
683
|
+
target,
|
|
684
|
+
cdpPort,
|
|
685
|
+
artifactsDir,
|
|
686
|
+
summaryPath,
|
|
687
|
+
reason: 'runtime_ready',
|
|
688
|
+
fix: '',
|
|
689
|
+
command,
|
|
690
|
+
});
|
|
691
|
+
printRuntimeLaunchReport(report, optionFlag(options, 'json'));
|
|
692
|
+
return 0;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const launchLogPath = path.join(artifactsDir, 'launch', 'logs', 'launch.log');
|
|
696
|
+
const report = runtimeLaunchReport('fail', {
|
|
697
|
+
adapter,
|
|
698
|
+
target,
|
|
699
|
+
cdpPort,
|
|
700
|
+
artifactsDir,
|
|
701
|
+
summaryPath: fs.existsSync(summaryPath) ? summaryPath : undefined,
|
|
702
|
+
launchLogPath: fs.existsSync(launchLogPath) ? launchLogPath : undefined,
|
|
703
|
+
reason: 'runtime_launch_failed',
|
|
704
|
+
fix: `Read ${fs.existsSync(launchLogPath) ? launchLogPath : summaryPath}, fix the first error, then rerun: ${runtimeLaunchCommand(target, cdpPort, chromeUserDataDir, startWatch)}`,
|
|
705
|
+
command,
|
|
706
|
+
exitCode: result.status ?? 1,
|
|
707
|
+
});
|
|
708
|
+
printRuntimeLaunchReport(report, optionFlag(options, 'json'));
|
|
709
|
+
return 1;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function runtimeLaunchCommand(target: string, cdpPort: number, chromeUserDataDir?: string, startWatch = false): string {
|
|
713
|
+
const base = `metamask-recipe runtime-launch --adapter extension --target ${shellQuote(target)} --cdp-port ${cdpPort}`;
|
|
714
|
+
const withMode = startWatch ? `${base} --start-watch` : base;
|
|
715
|
+
return chromeUserDataDir ? `${withMode} --chrome-user-data-dir ${shellQuote(chromeUserDataDir)}` : withMode;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function shellQuote(value: string): string {
|
|
719
|
+
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function runtimeLaunchReport(status: 'pass' | 'fail', fields: Record<string, unknown>): Record<string, unknown> {
|
|
723
|
+
return {
|
|
724
|
+
schemaVersion: 1,
|
|
725
|
+
status,
|
|
726
|
+
...fields,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function printRuntimeLaunchReport(report: Record<string, unknown>, json: boolean): void {
|
|
731
|
+
if (json) {
|
|
732
|
+
console.log(JSON.stringify(report, null, 2));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const status = String(report.status).toUpperCase();
|
|
736
|
+
console.log(`${status} runtime-launch ${report.reason}`);
|
|
737
|
+
if (report.status === 'fail') console.log(`Fix: ${report.fix}`);
|
|
738
|
+
console.log(`Artifacts: ${report.artifactsDir}`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function readJsonIfExists(file: string): unknown {
|
|
742
|
+
if (!fs.existsSync(file)) return null;
|
|
743
|
+
try {
|
|
744
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
|
|
751
|
+
const adapter = adapterOption(options);
|
|
752
|
+
const target = targetPath(options);
|
|
753
|
+
if (adapter === 'mobile') {
|
|
754
|
+
const watcherPortRaw =
|
|
755
|
+
optionString(options, 'watcherPort') ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT;
|
|
756
|
+
const watcherPort =
|
|
757
|
+
watcherPortRaw === undefined
|
|
758
|
+
? undefined
|
|
759
|
+
: parsePort(watcherPortRaw, 'runtime-decision --watcher-port must be a port.');
|
|
760
|
+
const report = await decideMobileReadiness(target, {
|
|
761
|
+
watcherPort,
|
|
762
|
+
metroLog: optionString(options, 'metroLog'),
|
|
763
|
+
platform: optionString(options, 'platform') ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
|
|
764
|
+
record: optionFlag(options, 'record'),
|
|
765
|
+
});
|
|
766
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
|
|
767
|
+
else console.log(`${report.decision} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
|
|
768
|
+
return 0;
|
|
769
|
+
}
|
|
770
|
+
if (adapter !== 'extension') {
|
|
771
|
+
const report = {
|
|
772
|
+
schemaVersion: 1,
|
|
773
|
+
adapter,
|
|
774
|
+
target,
|
|
775
|
+
decision: 'unknown',
|
|
776
|
+
clean: false,
|
|
777
|
+
reasonCode: 'adapter-unsupported',
|
|
778
|
+
reasons: [`runtime-decision applies to extension and mobile adapters, not ${adapter}.`],
|
|
779
|
+
checks: {},
|
|
780
|
+
actions: [],
|
|
781
|
+
};
|
|
782
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
|
|
783
|
+
else console.log(`unknown adapter-unsupported — ${report.reasons[0]}`);
|
|
784
|
+
return 0;
|
|
785
|
+
}
|
|
786
|
+
const cdpPortRaw = optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
|
|
787
|
+
const cdpPort = cdpPortRaw === undefined ? undefined : parsePort(cdpPortRaw, 'runtime-decision --cdp-port must be a port.');
|
|
788
|
+
const report = await decideExtensionReadiness(target, {
|
|
789
|
+
cdpPort,
|
|
790
|
+
watchLog: optionString(options, 'watchLog'),
|
|
791
|
+
record: optionFlag(options, 'record'),
|
|
792
|
+
});
|
|
793
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
|
|
794
|
+
else console.log(`${report.decision}${report.clean ? ' (clean)' : ''} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
|
|
795
|
+
return 0;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function handleResolveExtension({ options }: ParsedArgs): Promise<number> {
|
|
799
|
+
const adapter = adapterOption(options);
|
|
800
|
+
if (adapter !== 'extension') throw new Error('resolve-extension currently applies to the extension adapter.');
|
|
801
|
+
const target = targetPath(options);
|
|
802
|
+
const cdpPortRaw = optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
|
|
803
|
+
const cdpPort = cdpPortRaw === undefined ? undefined : parsePort(cdpPortRaw, 'resolve-extension --cdp-port must be a port.');
|
|
804
|
+
const result = await resolveExtensionId(target, { cdpPort });
|
|
805
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(result, null, 2));
|
|
806
|
+
else if (result.extensionId) console.log(result.extensionId); // bare id: easy `$(... resolve-extension ...)` capture
|
|
807
|
+
else console.error('Could not resolve a MetaMask extension id (no dist key and no single CDP extension).');
|
|
808
|
+
return result.extensionId ? 0 : 1;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function handleEnsureReady({ options }: ParsedArgs): Promise<number> {
|
|
812
|
+
const adapter = adapterOption(options);
|
|
813
|
+
if (adapter !== 'extension') throw new Error('ensure-ready currently applies to the extension adapter.');
|
|
814
|
+
const target = targetPath(options);
|
|
815
|
+
const cdpPort = parsePort(
|
|
816
|
+
optionString(options, 'cdpPort') ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT,
|
|
817
|
+
'ensure-ready requires --cdp-port <port>.',
|
|
818
|
+
);
|
|
819
|
+
const result = await ensureExtensionReady(target, { cdpPort });
|
|
820
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(result, null, 2));
|
|
821
|
+
else console.log(`${result.ready ? 'READY' : 'NOT-READY'} ${result.reasonCode} — id=${result.extensionId} homeTabs ${result.homeTabs.before}→${result.homeTabs.after} (closed ${result.homeTabs.closed})`);
|
|
822
|
+
return result.ready ? 0 : 1;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Exit-code taxonomy (docs/CLI-SPEC.md §5.6) — consistent across commands:
|
|
826
|
+
// 0 ok · 1 recipe/runtime fail (app-logic) · 2 usage / bad args ·
|
|
827
|
+
// 3 infra not auto-healed · 4 bounded / not-retryable ·
|
|
828
|
+
// 5 validation failure (run, run --plan, call — adapter-aware validation errors).
|
|
829
|
+
const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 } as const;
|
|
830
|
+
|
|
831
|
+
// Typed exit-code error: carries exitCode so both the global catch (metamask-recipe bin)
|
|
832
|
+
// and the mm-harness delegate() wrapper classify the error correctly rather than always
|
|
833
|
+
// returning 1. Usage errors (bad args / unsupported flags) carry EXIT.usage (2);
|
|
834
|
+
// validation errors carry EXIT.validation (5). Never throw a plain new Error() for
|
|
835
|
+
// user-facing bad-args cases — use usageError() so the exit code is preserved.
|
|
836
|
+
class CliError extends Error {
|
|
837
|
+
readonly exitCode: number;
|
|
838
|
+
constructor(message: string, exitCode: number) {
|
|
839
|
+
super(message);
|
|
840
|
+
this.name = 'CliError';
|
|
841
|
+
this.exitCode = exitCode;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function usageError(message: string): CliError {
|
|
845
|
+
return new CliError(message, EXIT.usage);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Shared adapter resolution: explicit --adapter/--platform, else auto-detect from
|
|
849
|
+
// --target/cwd (the exact detect story doctor/verify/install use). Lets `call`,
|
|
850
|
+
// `run --plan`, and `completion-candidates` work in a checkout without a flag.
|
|
851
|
+
function resolveAdapter(options: CliOptions): { adapter: MetaMaskRecipeAdapter; target: string } {
|
|
852
|
+
const target = targetPath(options);
|
|
853
|
+
const explicit = optionString(options, 'adapter') ?? optionString(options, 'platform');
|
|
854
|
+
const adapter = explicit ?? detectAdapter(target);
|
|
855
|
+
if (!adapter) {
|
|
856
|
+
throw usageError(
|
|
857
|
+
`could not detect the MetaMask repo type for ${target}\n Next: pass --adapter mobile|extension|core`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
assertAdapter(adapter);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
throw usageError(error instanceof Error ? error.message : String(error));
|
|
864
|
+
}
|
|
865
|
+
return { adapter, target };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Adapter-aware recipe validation: schema (validateRecipeDocument) + action
|
|
869
|
+
// 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.
|
|
877
|
+
async function validateRecipeAdapterAware(
|
|
878
|
+
recipe: unknown,
|
|
879
|
+
manifest: RecipeActionManifestDocument,
|
|
880
|
+
): Promise<RecipeValidationResult> {
|
|
881
|
+
const { validateRecipeDocument, validateRecipeWithManifest } = await importRecipeProtocol();
|
|
882
|
+
const schema = validateRecipeDocument(recipe);
|
|
883
|
+
const withManifest = validateRecipeWithManifest(recipe, manifest);
|
|
884
|
+
const findings = [...schema.findings, ...withManifest.findings];
|
|
885
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length;
|
|
886
|
+
const warnings = findings.length - errors;
|
|
887
|
+
return { status: errors > 0 ? 'invalid' : 'valid', findings, summary: { errors, warnings } };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
interface PlanItem {
|
|
891
|
+
step: string;
|
|
892
|
+
confidence: 'static' | 'conditional';
|
|
893
|
+
status: 'ok' | 'error' | 'planned';
|
|
894
|
+
detail: string;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function countRecipeNodes(recipe: unknown): number | undefined {
|
|
898
|
+
if (!isRecord(recipe)) return undefined;
|
|
899
|
+
const validate = isRecord(recipe.validate) ? recipe.validate : undefined;
|
|
900
|
+
const workflow = validate && isRecord(validate.workflow) ? validate.workflow : undefined;
|
|
901
|
+
const nodes = workflow && isRecord(workflow.nodes) ? workflow.nodes : undefined;
|
|
902
|
+
return nodes ? Object.keys(nodes).length : undefined;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Static, adapter-aware recipe validation shared by `run --plan` and the `run`
|
|
906
|
+
// execute path so both "validate first" the same way: read the file (missing /
|
|
907
|
+
// 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.
|
|
911
|
+
interface RunRecipeStaticValidation {
|
|
912
|
+
recipe: unknown;
|
|
913
|
+
recipeFile: string;
|
|
914
|
+
findings: RecipeValidationFinding[];
|
|
915
|
+
errorCount: number;
|
|
916
|
+
manifestOk: boolean;
|
|
917
|
+
schemaValid: boolean;
|
|
918
|
+
usageError?: { code: string; message: string };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function validateRunRecipeStatic(
|
|
922
|
+
recipeArg: string,
|
|
923
|
+
adapter: MetaMaskRecipeAdapter,
|
|
924
|
+
options: CliOptions,
|
|
925
|
+
): Promise<RunRecipeStaticValidation> {
|
|
926
|
+
const recipeFile = path.resolve(recipeArg);
|
|
927
|
+
const empty = { recipe: undefined, recipeFile, findings: [], errorCount: 0, manifestOk: false, schemaValid: false };
|
|
928
|
+
if (!fs.existsSync(recipeFile)) {
|
|
929
|
+
return { ...empty, usageError: { code: 'RECIPE_NOT_FOUND', message: `recipe not found: ${recipeFile}` } };
|
|
930
|
+
}
|
|
931
|
+
let recipe: unknown;
|
|
932
|
+
try {
|
|
933
|
+
recipe = JSON.parse(fs.readFileSync(recipeFile, 'utf8'));
|
|
934
|
+
} catch (error) {
|
|
935
|
+
return {
|
|
936
|
+
...empty,
|
|
937
|
+
usageError: {
|
|
938
|
+
code: 'RECIPE_UNPARSEABLE',
|
|
939
|
+
message: `recipe is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// validate.manifest (static) — manifest well-formedness (same check doctor runs).
|
|
945
|
+
const findings: RecipeValidationFinding[] = [];
|
|
946
|
+
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
947
|
+
let manifestOk = true;
|
|
948
|
+
try {
|
|
949
|
+
await validateManifest(manifest);
|
|
950
|
+
} catch (error) {
|
|
951
|
+
manifestOk = false;
|
|
952
|
+
findings.push({
|
|
953
|
+
severity: 'error',
|
|
954
|
+
code: 'manifest.invalid',
|
|
955
|
+
path: actionManifestPathOption(options, adapter),
|
|
956
|
+
message: error instanceof Error ? error.message : String(error),
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// validate.schema + validate.actions (static) — adapter-aware recipe validation.
|
|
961
|
+
const validation = manifestOk
|
|
962
|
+
? await validateRecipeAdapterAware(recipe, manifest)
|
|
963
|
+
: { status: 'invalid' as const, findings: [], summary: { errors: 1, warnings: 0 } };
|
|
964
|
+
findings.push(...validation.findings);
|
|
965
|
+
|
|
966
|
+
const errorCount = findings.filter((finding) => finding.severity === 'error').length;
|
|
967
|
+
return { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid: validation.status === 'valid' };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// `run --plan`: validate (adapter-aware) + print the plan — touches NOTHING (no
|
|
971
|
+
// device, no overlay mutation, no artifacts written). Exit 0 = plan valid; exit 5
|
|
972
|
+
// = validation failed. `--json` = { status, adapter, recipe, findings[], plan[] }.
|
|
973
|
+
async function handleRunPlan(recipeArg: string, options: CliOptions): Promise<number> {
|
|
974
|
+
const json = optionFlag(options, 'json');
|
|
975
|
+
const { adapter, target } = resolveAdapter(options);
|
|
976
|
+
|
|
977
|
+
const validated = await validateRunRecipeStatic(recipeArg, adapter, options);
|
|
978
|
+
if (validated.usageError) {
|
|
979
|
+
return emitPlanUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
|
|
980
|
+
}
|
|
981
|
+
const { recipe, recipeFile, findings, errorCount, manifestOk, schemaValid } = validated;
|
|
982
|
+
|
|
983
|
+
const status: 'pass' | 'fail' = errorCount === 0 ? 'pass' : 'fail';
|
|
984
|
+
const nodeCount = countRecipeNodes(recipe);
|
|
985
|
+
const artifactsDir = optionString(options, 'artifactsDir');
|
|
986
|
+
|
|
987
|
+
const plan: PlanItem[] = [
|
|
988
|
+
{ step: 'resolve.recipe', confidence: 'static', status: 'ok', detail: recipeFile },
|
|
989
|
+
{ step: 'resolve.adapter', confidence: 'static', status: 'ok', detail: adapter },
|
|
990
|
+
{
|
|
991
|
+
step: 'resolve.artifactsDir',
|
|
992
|
+
confidence: 'static',
|
|
993
|
+
status: 'ok',
|
|
994
|
+
detail: artifactsDir ? path.resolve(artifactsDir) : '(resolved to the slot artifacts dir at run time)',
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
step: 'validate.manifest',
|
|
998
|
+
confidence: 'static',
|
|
999
|
+
status: manifestOk ? 'ok' : 'error',
|
|
1000
|
+
detail: manifestOk ? 'action manifest is well-formed' : 'action manifest failed validation',
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
step: 'validate.schema',
|
|
1004
|
+
confidence: 'static',
|
|
1005
|
+
status: manifestOk && schemaValid ? 'ok' : 'error',
|
|
1006
|
+
detail: 'recipe document schema + action existence/platform vs the adapter manifest',
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
step: 'fixture.file',
|
|
1010
|
+
confidence: 'static',
|
|
1011
|
+
status: 'ok',
|
|
1012
|
+
detail: fs.existsSync(walletFixturePath(target)) ? `present: ${walletFixturePath(target)}` : `absent: ${walletFixturePath(target)} (fixtures set to seed)`,
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
step: 'overlay.ensure',
|
|
1016
|
+
confidence: 'conditional',
|
|
1017
|
+
status: 'planned',
|
|
1018
|
+
detail: 'would auto-ensure the runtime overlay if missing (install phase)',
|
|
1019
|
+
},
|
|
1020
|
+
...(adapter === 'core'
|
|
1021
|
+
? []
|
|
1022
|
+
: [
|
|
1023
|
+
{
|
|
1024
|
+
step: 'launch.app',
|
|
1025
|
+
confidence: 'conditional' as const,
|
|
1026
|
+
status: 'planned' as const,
|
|
1027
|
+
detail: 'would launch/attach the app + heal transport (Metro/Chrome/CDP) before executing',
|
|
1028
|
+
},
|
|
1029
|
+
]),
|
|
1030
|
+
{
|
|
1031
|
+
step: 'execute.nodes',
|
|
1032
|
+
confidence: 'conditional',
|
|
1033
|
+
status: 'planned',
|
|
1034
|
+
detail: nodeCount === undefined ? 'would execute the recipe nodes' : `would execute ${nodeCount} recipe node(s)`,
|
|
1035
|
+
},
|
|
1036
|
+
];
|
|
1037
|
+
|
|
1038
|
+
if (json) {
|
|
1039
|
+
const payload: Record<string, unknown> = { schemaVersion: 1, command: 'run', mode: 'plan', status, adapter, recipe: recipeFile, findings, plan };
|
|
1040
|
+
if (status === 'fail') payload.error = { code: 'RECIPE_VALIDATION_FAILED', message: `recipe validation found ${errorCount} error(s)` };
|
|
1041
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1042
|
+
} else {
|
|
1043
|
+
console.log(`plan ${status} — ${adapter} — ${recipeFile}`);
|
|
1044
|
+
for (const item of plan) {
|
|
1045
|
+
const mark = item.status === 'error' ? '✗' : item.status === 'ok' ? '✓' : '·';
|
|
1046
|
+
console.log(` ${mark} [${item.confidence}] ${item.step}: ${item.detail}`);
|
|
1047
|
+
}
|
|
1048
|
+
if (findings.length) {
|
|
1049
|
+
console.log('findings:');
|
|
1050
|
+
for (const finding of findings) {
|
|
1051
|
+
console.log(` ${finding.severity === 'error' ? '✗' : '⚠'} ${finding.code} ${finding.path} — ${finding.message}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
return status === 'pass' ? EXIT.ok : EXIT.validation;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function emitPlanUsageError(
|
|
1059
|
+
json: boolean,
|
|
1060
|
+
adapter: MetaMaskRecipeAdapter,
|
|
1061
|
+
recipeFile: string,
|
|
1062
|
+
code: string,
|
|
1063
|
+
message: string,
|
|
1064
|
+
): number {
|
|
1065
|
+
if (json) {
|
|
1066
|
+
console.log(
|
|
1067
|
+
JSON.stringify(
|
|
1068
|
+
{ schemaVersion: 1, command: 'run', mode: 'plan', status: 'fail', adapter, recipe: recipeFile, error: { code, message } },
|
|
1069
|
+
null,
|
|
1070
|
+
2,
|
|
1071
|
+
),
|
|
1072
|
+
);
|
|
1073
|
+
} else {
|
|
1074
|
+
console.error(`✗ run --plan: ${message}`);
|
|
1075
|
+
}
|
|
1076
|
+
return EXIT.usage;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// `call <action>`: execute ONE action from the adapter vocabulary AS a single-node
|
|
1080
|
+
// recipe through the real engine path — the SAME path `run` uses (write a recipe,
|
|
1081
|
+
// hand it to runRecipe). One execution path, two doors: `call` = one node, `run` =
|
|
1082
|
+
// a graph. Inherits run semantics: always-validates (adapter-aware, exit 5), same
|
|
1083
|
+
// trace/evidence artifacts, same --json contract.
|
|
1084
|
+
async function handleCall(argv: string[]): Promise<number> {
|
|
1085
|
+
// Grammar: mm-harness call <action> [--arg k=v ...] [flags].
|
|
1086
|
+
// If the first token is a flag, the action positional is missing — parseCallArgs
|
|
1087
|
+
// would wrongly consume the flag's VALUE as the action name (e.g. `--adapter core`
|
|
1088
|
+
// → action='core'). Catch it early before any parsing so the error is accurate.
|
|
1089
|
+
if (argv.length > 0 && argv[0].startsWith('--')) {
|
|
1090
|
+
const message = 'call requires <action> first: mm-harness call <action> [--arg k=v ...] [flags]';
|
|
1091
|
+
console.error(message);
|
|
1092
|
+
return EXIT.usage;
|
|
1093
|
+
}
|
|
1094
|
+
const { action: shortName, args, rest } = parseCallArgs(argv);
|
|
1095
|
+
const { options } = parseArgs(rest, 'call');
|
|
1096
|
+
const json = optionFlag(options, 'json');
|
|
1097
|
+
if (!shortName) {
|
|
1098
|
+
const message = 'call requires <action>. Example: mm-harness call unlock --adapter extension';
|
|
1099
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', error: { code: 'USAGE', message } }, null, 2));
|
|
1100
|
+
else console.error(message);
|
|
1101
|
+
return EXIT.usage;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const { adapter, target } = resolveAdapter(options);
|
|
1105
|
+
|
|
1106
|
+
// Pre-execution bound: refuse while another recipe is running. Checked here
|
|
1107
|
+
// (before manifest load) so any action name produces exit 4, not an action
|
|
1108
|
+
// resolution error, when a recipe.lock is present.
|
|
1109
|
+
if (recipeRunning(target)) {
|
|
1110
|
+
const msg = 'a recipe is currently running — refusing to start while another recipe executes.';
|
|
1111
|
+
if (json) {
|
|
1112
|
+
console.log(JSON.stringify({ schemaVersion: 1, command: 'call', status: 'fail', recoverable: false, error: { code: 'RECIPE_RUNNING', message: msg } }, null, 2));
|
|
1113
|
+
} else {
|
|
1114
|
+
console.error(`✗ mm-harness call: ${msg}`);
|
|
1115
|
+
}
|
|
1116
|
+
return EXIT.bounded;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const actionManifestOverride = optionString(options, 'actionManifest');
|
|
1120
|
+
const manifest = loadActionManifest(adapter, actionManifestOverride);
|
|
1121
|
+
const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
|
|
1122
|
+
const names = getRecipeActionManifestActionNames(manifest);
|
|
1123
|
+
|
|
1124
|
+
const resolution = resolveActionName(shortName, names);
|
|
1125
|
+
if (resolution.status === 'unknown') {
|
|
1126
|
+
const message =
|
|
1127
|
+
`✗ call: unknown action "${shortName}" for the ${adapter} adapter.\n` +
|
|
1128
|
+
` See the vocabulary: mm-harness actions --adapter ${adapter} --json`;
|
|
1129
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', adapter, action: shortName, error: { code: 'ACTION_UNKNOWN', message } }, null, 2));
|
|
1130
|
+
else console.error(message);
|
|
1131
|
+
return EXIT.usage;
|
|
1132
|
+
}
|
|
1133
|
+
if (resolution.status === 'ambiguous') {
|
|
1134
|
+
const message = `✗ call: "${shortName}" is ambiguous: ${resolution.candidates.join(', ')} — use the full name.`;
|
|
1135
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', adapter, action: shortName, error: { code: 'ACTION_AMBIGUOUS', message, candidates: resolution.candidates } }, null, 2));
|
|
1136
|
+
else console.error(message);
|
|
1137
|
+
return EXIT.usage;
|
|
1138
|
+
}
|
|
1139
|
+
const resolvedAction = resolution.resolved;
|
|
1140
|
+
|
|
1141
|
+
// Synthesize the one-node recipe (entry action → end). This IS the recipe `run`
|
|
1142
|
+
// would execute; call just authored it from the CLI instead of a file.
|
|
1143
|
+
const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
|
|
1144
|
+
|
|
1145
|
+
// Always-validate first (adapter-aware) — exit 5 on validation failure, before
|
|
1146
|
+
// the engine touches anything.
|
|
1147
|
+
const validation = await validateRecipeAdapterAware(recipe, manifest);
|
|
1148
|
+
if (validation.status === 'invalid') {
|
|
1149
|
+
const message = `✗ call ${resolvedAction}: recipe validation failed`;
|
|
1150
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', adapter, action: shortName, resolvedAction, args, findings: validation.findings, error: { code: 'RECIPE_VALIDATION_FAILED', message } }, null, 2));
|
|
1151
|
+
else {
|
|
1152
|
+
console.error(message);
|
|
1153
|
+
for (const finding of validation.findings) console.error(` ${finding.code} ${finding.path} — ${finding.message}`);
|
|
1154
|
+
}
|
|
1155
|
+
return EXIT.validation;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// --heal (call inherits infra-only) + auto-ensure the runtime overlay before
|
|
1159
|
+
// execution. core is headless → no-op.
|
|
1160
|
+
const prepared = await prepareHeal(adapter, target, options, json);
|
|
1161
|
+
if (typeof prepared === 'number') return prepared;
|
|
1162
|
+
const { state, heal } = prepared;
|
|
1163
|
+
|
|
1164
|
+
// Write the synthesized recipe to a temp file and run it through the exact same
|
|
1165
|
+
// engine path `run` uses (runRecipe → runner.run). Evidence lands in artifactsDir.
|
|
1166
|
+
const scratch = await mkdtemp(path.join(os.tmpdir(), 'mm-harness-call-'));
|
|
1167
|
+
const recipeFile = path.join(scratch, 'call.recipe.json');
|
|
1168
|
+
fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}\n`);
|
|
1169
|
+
const artifactsDir = optionString(options, 'artifactsDir') ?? path.join(scratch, 'artifacts');
|
|
1170
|
+
|
|
1171
|
+
const librarySources = await resolveMetaMaskLibrarySources(optionString(options, 'library'));
|
|
1172
|
+
const callRuntimeOptions: RuntimeOptions = {
|
|
1173
|
+
...runtimeOptionsFromCli(options),
|
|
1174
|
+
...(librarySources ? { librarySources } : {}),
|
|
1175
|
+
stdoutIsMachineContract: json,
|
|
1176
|
+
};
|
|
1177
|
+
// Failure/retry path wired through the shared checkHealBounds module, identical
|
|
1178
|
+
// to `run`: app-logic verbatim/no-heal, bounded infra recovery, and the
|
|
1179
|
+
// same-recovery-twice refusal.
|
|
1180
|
+
const { result, violation } = await executeWithHealBounds(
|
|
1181
|
+
() => runRecipe(adapter, recipeFile, artifactsDir, target, actionManifestOverride, callRuntimeOptions),
|
|
1182
|
+
adapter,
|
|
1183
|
+
target,
|
|
1184
|
+
heal,
|
|
1185
|
+
state,
|
|
1186
|
+
);
|
|
1187
|
+
if (violation !== null) return emitHealViolation(json, 'call', result, violation, state);
|
|
1188
|
+
|
|
1189
|
+
if (json) {
|
|
1190
|
+
console.log(
|
|
1191
|
+
JSON.stringify(
|
|
1192
|
+
{
|
|
1193
|
+
schemaVersion: 1,
|
|
1194
|
+
command: 'call',
|
|
1195
|
+
adapter,
|
|
1196
|
+
action: shortName,
|
|
1197
|
+
resolvedAction,
|
|
1198
|
+
args,
|
|
1199
|
+
status: result.status,
|
|
1200
|
+
summaryPath: result.summaryPath,
|
|
1201
|
+
tracePath: result.tracePath,
|
|
1202
|
+
artifactManifestPath: result.artifactManifestPath,
|
|
1203
|
+
recovered: state.recovered,
|
|
1204
|
+
mutations: state.mutations,
|
|
1205
|
+
exitCode: result.status === 'pass' ? EXIT.ok : EXIT.runtime,
|
|
1206
|
+
},
|
|
1207
|
+
null,
|
|
1208
|
+
2,
|
|
1209
|
+
),
|
|
1210
|
+
);
|
|
1211
|
+
} else {
|
|
1212
|
+
console.log(`call ${resolvedAction}: ${result.status}\nArtifacts: ${result.artifactManifestPath}`);
|
|
1213
|
+
}
|
|
1214
|
+
return result.status === 'pass' ? EXIT.ok : EXIT.runtime;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
interface CallArgs {
|
|
1218
|
+
action: string | undefined;
|
|
1219
|
+
args: Record<string, string>;
|
|
1220
|
+
rest: string[];
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// Pull the positional <action> and repeatable `--arg k=v` out of argv; everything
|
|
1224
|
+
// else (--adapter/--json/--target/…) flows to the shared parseArgs.
|
|
1225
|
+
function parseCallArgs(argv: string[]): CallArgs {
|
|
1226
|
+
const args: Record<string, string> = {};
|
|
1227
|
+
const rest: string[] = [];
|
|
1228
|
+
let action: string | undefined;
|
|
1229
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1230
|
+
const arg = argv[i];
|
|
1231
|
+
if (arg === '--arg' || arg.startsWith('--arg=')) {
|
|
1232
|
+
const pair = arg === '--arg' ? argv[(i += 1)] : arg.slice('--arg='.length);
|
|
1233
|
+
if (pair === undefined) throw usageError('--arg requires k=v.');
|
|
1234
|
+
const eq = pair.indexOf('=');
|
|
1235
|
+
if (eq === -1) throw usageError(`--arg must be k=v: ${pair}`);
|
|
1236
|
+
args[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
if (!arg.startsWith('--') && action === undefined) {
|
|
1240
|
+
action = arg;
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
rest.push(arg);
|
|
1244
|
+
}
|
|
1245
|
+
return { action, args, rest };
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
interface ActionResolution {
|
|
1249
|
+
status: 'ok' | 'ambiguous' | 'unknown';
|
|
1250
|
+
resolved: string;
|
|
1251
|
+
candidates: string[];
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Fuzzy short-name resolution, most-specific tier first (exact beats substring):
|
|
1255
|
+
// 1. exact full action name (`metamask.wallet.ensure_unlocked`);
|
|
1256
|
+
// 2. exact final dot-segment (`unlock` → `metamask.wallet.unlock`);
|
|
1257
|
+
// 3. unique substring of a final segment (`unlock` ⊂ `ensure_unlocked`).
|
|
1258
|
+
// Within the first tier that has any match: 1 → ok, >1 → ambiguous. Only a fully
|
|
1259
|
+
// empty tier falls through, so an exact match is never overridden by a substring.
|
|
1260
|
+
function resolveActionName(shortName: string, names: string[]): ActionResolution {
|
|
1261
|
+
if (names.includes(shortName)) return { status: 'ok', resolved: shortName, candidates: [shortName] };
|
|
1262
|
+
const finalSegment = (name: string): string => name.split('.').pop() ?? name;
|
|
1263
|
+
const exactSegment = names.filter((name) => finalSegment(name) === shortName);
|
|
1264
|
+
const tier = exactSegment.length > 0 ? exactSegment : names.filter((name) => finalSegment(name).includes(shortName));
|
|
1265
|
+
if (tier.length === 1) return { status: 'ok', resolved: tier[0], candidates: tier };
|
|
1266
|
+
if (tier.length > 1) return { status: 'ambiguous', resolved: '', candidates: tier };
|
|
1267
|
+
return { status: 'unknown', resolved: '', candidates: [] };
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function synthesizeOneNodeRecipe(action: string, args: Record<string, string>): Record<string, unknown> {
|
|
1271
|
+
return {
|
|
1272
|
+
schema_version: 1,
|
|
1273
|
+
title: `mm-harness call ${action}`,
|
|
1274
|
+
description: `Ad-hoc single-action execution of ${action} via the real engine path (mm-harness call).`,
|
|
1275
|
+
validate: {
|
|
1276
|
+
workflow: {
|
|
1277
|
+
entry: 'call',
|
|
1278
|
+
nodes: {
|
|
1279
|
+
call: { action, ...args, next: 'done', intent: `Call ${action} in isolation` },
|
|
1280
|
+
done: { action: 'end', status: 'pass' },
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
},
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Reusable one-node engine path (the same machinery `call` uses), exposed so
|
|
1288
|
+
// `fixtures set` can apply the canonical wallet fixture through the real engine on
|
|
1289
|
+
// the extension (which has no standalone porcelain set arm).
|
|
1290
|
+
async function runOneNode(
|
|
1291
|
+
adapter: MetaMaskRecipeAdapter,
|
|
1292
|
+
action: string,
|
|
1293
|
+
args: Record<string, string>,
|
|
1294
|
+
target: string,
|
|
1295
|
+
actionManifest: string | undefined,
|
|
1296
|
+
): Promise<{ status: 'pass' | 'fail' }> {
|
|
1297
|
+
const manifest = loadActionManifest(adapter, actionManifest);
|
|
1298
|
+
const recipe = synthesizeOneNodeRecipe(action, args);
|
|
1299
|
+
const validation = await validateRecipeAdapterAware(recipe, manifest);
|
|
1300
|
+
if (validation.status === 'invalid') return { status: 'fail' };
|
|
1301
|
+
const scratch = await mkdtemp(path.join(os.tmpdir(), 'mm-harness-fixtures-'));
|
|
1302
|
+
const recipeFile = path.join(scratch, 'set.recipe.json');
|
|
1303
|
+
fs.writeFileSync(recipeFile, `${JSON.stringify(recipe, null, 2)}\n`);
|
|
1304
|
+
const result = await runRecipe(adapter, recipeFile, path.join(scratch, 'artifacts'), target, actionManifest, {
|
|
1305
|
+
librarySources: await resolveMetaMaskLibrarySources(undefined),
|
|
1306
|
+
});
|
|
1307
|
+
return { status: result.status === 'pass' ? 'pass' : 'fail' };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Pre-execution heal setup shared by `run` and `call` (both default `infra-only`).
|
|
1311
|
+
// Parses --heal, then auto-ensures the runtime overlay for the device adapters
|
|
1312
|
+
// (mobile/extension). core is headless — its engine path needs no launch overlay.
|
|
1313
|
+
// Returns the heal state + policy on success, or an exit code (number) on failure
|
|
1314
|
+
// (invalid --heal → usage; overlay install failed → infra) — a typeof-narrowable
|
|
1315
|
+
// result so this works regardless of strict-mode settings.
|
|
1316
|
+
interface PreparedHeal {
|
|
1317
|
+
state: HealState;
|
|
1318
|
+
heal: HealPolicy;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function prepareHeal(
|
|
1322
|
+
adapter: MetaMaskRecipeAdapter,
|
|
1323
|
+
target: string,
|
|
1324
|
+
options: CliOptions,
|
|
1325
|
+
json: boolean,
|
|
1326
|
+
): Promise<PreparedHeal | number> {
|
|
1327
|
+
// Bound: refuse while another recipe is running (re-entrant run would corrupt
|
|
1328
|
+
// in-flight state). Enforced regardless of --heal policy. Also checked earlier
|
|
1329
|
+
// in handleCall (before action resolution) so the test can use any action name.
|
|
1330
|
+
if (recipeRunning(target)) {
|
|
1331
|
+
const msg = 'a recipe is currently running — refusing to start while another recipe executes.';
|
|
1332
|
+
if (json) {
|
|
1333
|
+
console.log(JSON.stringify({ schemaVersion: 1, status: 'fail', recoverable: false, error: { code: 'RECIPE_RUNNING', message: msg } }, null, 2));
|
|
1334
|
+
} else {
|
|
1335
|
+
console.error(`✗ mm-harness: ${msg}`);
|
|
1336
|
+
}
|
|
1337
|
+
return EXIT.bounded;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const healValue = optionString(options, 'heal');
|
|
1341
|
+
const healOpts: Record<string, string | boolean> = {};
|
|
1342
|
+
if (healValue !== undefined) healOpts.heal = healValue;
|
|
1343
|
+
const heal = parseHeal(healOpts, 'infra-only');
|
|
1344
|
+
if (typeof heal !== 'string') {
|
|
1345
|
+
console.error((heal as { error: string }).error);
|
|
1346
|
+
return EXIT.usage;
|
|
1347
|
+
}
|
|
1348
|
+
const state = newHealState();
|
|
1349
|
+
const ensured = await ensureOverlay(adapter, target, heal as HealPolicy, state, json);
|
|
1350
|
+
if (!ensured.ok) {
|
|
1351
|
+
if (json) {
|
|
1352
|
+
console.log(
|
|
1353
|
+
JSON.stringify(
|
|
1354
|
+
{ schemaVersion: 1, status: 'fail', recoverable: false, mutations: state.mutations, error: { code: 'OVERLAY_INSTALL_FAILED', message: ensured.error } },
|
|
1355
|
+
null,
|
|
1356
|
+
2,
|
|
1357
|
+
),
|
|
1358
|
+
);
|
|
1359
|
+
} else {
|
|
1360
|
+
console.error(`✗ overlay auto-ensure failed: ${ensured.error}`);
|
|
1361
|
+
}
|
|
1362
|
+
return EXIT.infra;
|
|
1363
|
+
}
|
|
1364
|
+
return { state, heal: heal as HealPolicy };
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// Recovery codes for the run/call heal loop — the analog of the launch recovery
|
|
1368
|
+
// codes. core is headless (no transport) but still carries a code so the shared
|
|
1369
|
+
// same-recovery-twice bound can fire and stop blind retry loops.
|
|
1370
|
+
const RUN_RECOVERY_CODE: Record<MetaMaskRecipeAdapter, string> = {
|
|
1371
|
+
mobile: 'metro.restarted',
|
|
1372
|
+
extension: 'chrome.reopened',
|
|
1373
|
+
core: 'runtime.reset',
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
// Collect the failed nodes' error text from a run's trace.json, verbatim, so the
|
|
1377
|
+
// shared checkHealBounds can classify the failure (app-logic vs infra vs wallet)
|
|
1378
|
+
// and so the ORIGINAL cause survives into the --json/human output.
|
|
1379
|
+
function readRunFailureText(result: RecipeRunResult): string {
|
|
1380
|
+
try {
|
|
1381
|
+
const trace = JSON.parse(fs.readFileSync(result.tracePath, 'utf8')) as {
|
|
1382
|
+
entries?: Array<{ ok?: boolean; error?: unknown }>;
|
|
1383
|
+
};
|
|
1384
|
+
const entries = Array.isArray(trace.entries) ? trace.entries : [];
|
|
1385
|
+
return entries
|
|
1386
|
+
.filter((entry) => entry && entry.ok === false && typeof entry.error === 'string')
|
|
1387
|
+
.map((entry) => entry.error as string)
|
|
1388
|
+
.join('\n')
|
|
1389
|
+
.trim();
|
|
1390
|
+
} catch {
|
|
1391
|
+
return '';
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Shared run/call failure-and-retry path wired through the same checkHealBounds
|
|
1396
|
+
// module launch uses. On failure it classifies the verbatim trace output:
|
|
1397
|
+
// app-logic → surface verbatim, no heal (exit 1); wallet → refuse (exit 4);
|
|
1398
|
+
// recipe-running → refuse (exit 4). Infra failures get ONE bounded recovery re-run
|
|
1399
|
+
// (heal != off); a second failure trips the same-recovery-twice bound (exit 4).
|
|
1400
|
+
// `state.recovered[]`/`attemptedRecoveries[]` are mutated for the --json contract.
|
|
1401
|
+
// Returns the final result plus the violation (if any) to surface.
|
|
1402
|
+
async function executeWithHealBounds(
|
|
1403
|
+
exec: () => Promise<RecipeRunResult>,
|
|
1404
|
+
adapter: MetaMaskRecipeAdapter,
|
|
1405
|
+
target: string,
|
|
1406
|
+
heal: HealPolicy,
|
|
1407
|
+
state: HealState,
|
|
1408
|
+
): Promise<{ result: RecipeRunResult; violation: HealBoundViolation | null }> {
|
|
1409
|
+
let result = await exec();
|
|
1410
|
+
for (;;) {
|
|
1411
|
+
if (result.status === 'pass') return { result, violation: null };
|
|
1412
|
+
const violation = checkHealBounds(target, readRunFailureText(result), state);
|
|
1413
|
+
if (violation !== null) return { result, violation };
|
|
1414
|
+
// Unclassified/infra failure. heal=off preserves the exact broken state.
|
|
1415
|
+
if (heal === 'off') return { result, violation: null };
|
|
1416
|
+
// Bounded infra recovery: re-run once. Record BEFORE the retry so the next
|
|
1417
|
+
// checkHealBounds call sees it and refuses a second loop (same-recovery-twice).
|
|
1418
|
+
const recoveryCode = RUN_RECOVERY_CODE[adapter];
|
|
1419
|
+
state.attemptedRecoveries.push(recoveryCode);
|
|
1420
|
+
result = await exec();
|
|
1421
|
+
if (result.status === 'pass') {
|
|
1422
|
+
state.recovered.push(recoveryCode);
|
|
1423
|
+
return { result, violation: null };
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Hidden `completion-candidates <actions|flows>` — the source shell completion
|
|
1429
|
+
// scripts call it. Reads/writes the per-checkout completion cache so tab
|
|
1430
|
+
// completion never blocks on a manifest/library read. `flows` degrades gracefully
|
|
1431
|
+
// until @farmslot/recipe-harness >= 0.3.3 exposes the recipe-library API.
|
|
1432
|
+
async function handleCompletionCandidates(argv: string[]): Promise<number> {
|
|
1433
|
+
const kind = argv[0];
|
|
1434
|
+
const { options } = parseArgs(argv.slice(1), 'completion-candidates');
|
|
1435
|
+
const json = optionFlag(options, 'json');
|
|
1436
|
+
if (kind !== 'actions' && kind !== 'flows') {
|
|
1437
|
+
console.error('completion-candidates requires <actions|flows>.');
|
|
1438
|
+
return EXIT.usage;
|
|
1439
|
+
}
|
|
1440
|
+
// Actions are adapter-scoped (they come from the adapter manifest); library
|
|
1441
|
+
// flows are adapter-global, so flows completion works even outside a checkout.
|
|
1442
|
+
const target = targetPath(options);
|
|
1443
|
+
let adapter: MetaMaskRecipeAdapter | undefined;
|
|
1444
|
+
|
|
1445
|
+
let candidates: string[] | undefined;
|
|
1446
|
+
if (kind === 'actions') {
|
|
1447
|
+
adapter = resolveAdapter(options).adapter;
|
|
1448
|
+
candidates = readFreshCandidates(target, 'actions');
|
|
1449
|
+
if (!candidates) {
|
|
1450
|
+
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
1451
|
+
const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
|
|
1452
|
+
candidates = getRecipeActionManifestActionNames(manifest);
|
|
1453
|
+
writeCompletionCandidates(target, 'actions', candidates);
|
|
1454
|
+
}
|
|
1455
|
+
} else {
|
|
1456
|
+
candidates = readFreshCandidates(target, 'flows');
|
|
1457
|
+
if (!candidates) {
|
|
1458
|
+
const sources = await resolveMetaMaskLibrarySources(undefined).catch(() => undefined);
|
|
1459
|
+
if (!sources) {
|
|
1460
|
+
// recipe-library API absent: return empty (not an error) so the shell
|
|
1461
|
+
// falls back to static completion instead of blocking.
|
|
1462
|
+
candidates = [];
|
|
1463
|
+
} else {
|
|
1464
|
+
const harness = await importRecipeHarness();
|
|
1465
|
+
const resolution = await harness.loadRecipeLibraries(sources);
|
|
1466
|
+
candidates = [...resolution.flows.keys()].sort();
|
|
1467
|
+
writeCompletionCandidates(target, 'flows', candidates);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, kind, adapter, candidates }, null, 2));
|
|
1473
|
+
else for (const candidate of candidates) console.log(candidate);
|
|
1474
|
+
return EXIT.ok;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async function handleRun({ positional, options }: ParsedArgs): Promise<number> {
|
|
1478
|
+
const targetRecipe = positional[0];
|
|
1479
|
+
if (!targetRecipe) throw usageError('run requires <recipe.json>.');
|
|
1480
|
+
// run --plan: validate + print the plan, touch NOTHING (no device, no overlay).
|
|
1481
|
+
if (optionFlag(options, 'plan')) return handleRunPlan(targetRecipe, options);
|
|
1482
|
+
const adapter = adapterOption(options);
|
|
1483
|
+
const json = optionFlag(options, 'json');
|
|
1484
|
+
const target = targetPath(options);
|
|
1485
|
+
const artifactsDir = requiredOption(options, 'artifactsDir', 'run requires --artifacts-dir <dir>.');
|
|
1486
|
+
|
|
1487
|
+
// --heal (default infra-only) + auto-ensure the runtime overlay before
|
|
1488
|
+
// execution. core is headless → no-op. Also enforces the recipe-running bound
|
|
1489
|
+
// (exit 4) before the recipe file is read — that refusal must precede validation.
|
|
1490
|
+
const prepared = await prepareHeal(adapter, target, options, json);
|
|
1491
|
+
if (typeof prepared === 'number') return prepared;
|
|
1492
|
+
const { state, heal } = prepared;
|
|
1493
|
+
|
|
1494
|
+
// Validate first (adapter-aware) — the SAME static path `run --plan` uses, before
|
|
1495
|
+
// launching/executing or resolving libraries. A missing or unparseable recipe is
|
|
1496
|
+
// a usage error (exit 2); validation errors exit 5 with the structured JSON.
|
|
1497
|
+
// Nothing here logs "Recipe libraries: …", so the machine contract on stdout
|
|
1498
|
+
// stays pure on the validation-failure path.
|
|
1499
|
+
const validated = await validateRunRecipeStatic(targetRecipe, adapter, options);
|
|
1500
|
+
if (validated.usageError) {
|
|
1501
|
+
return emitRunUsageError(json, adapter, validated.recipeFile, validated.usageError.code, validated.usageError.message);
|
|
1502
|
+
}
|
|
1503
|
+
if (validated.errorCount > 0) {
|
|
1504
|
+
return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const librarySources = await resolveMetaMaskLibrarySources(optionString(options, 'library'));
|
|
1508
|
+
const runtimeOptions: RuntimeOptions = {
|
|
1509
|
+
...runtimeOptionsFromCli(options),
|
|
1510
|
+
...(librarySources ? { librarySources } : {}),
|
|
1511
|
+
stdoutIsMachineContract: json,
|
|
1512
|
+
};
|
|
1513
|
+
// Failure/retry path wired through the shared checkHealBounds module:
|
|
1514
|
+
// app-logic → verbatim no-heal; wallet/recipe-running → refuse; infra → one
|
|
1515
|
+
// bounded recovery re-run, second failure → same-recovery-twice.
|
|
1516
|
+
const { result, violation } = await executeWithHealBounds(
|
|
1517
|
+
() => runRecipe(adapter, targetRecipe, artifactsDir, target, optionString(options, 'actionManifest'), runtimeOptions),
|
|
1518
|
+
adapter,
|
|
1519
|
+
target,
|
|
1520
|
+
heal,
|
|
1521
|
+
state,
|
|
1522
|
+
);
|
|
1523
|
+
if (violation !== null) return emitHealViolation(json, 'run', result, violation, state);
|
|
1524
|
+
const exitCode = result.status === 'pass' ? EXIT.ok : EXIT.runtime;
|
|
1525
|
+
if (json) {
|
|
1526
|
+
// mm-harness envelope (matches launch/verify/doctor): the engine's result is
|
|
1527
|
+
// nested under `result`; the heal contract (recovered[]/mutations[]) and the
|
|
1528
|
+
// resolved exitCode ride the envelope regardless of adapter.
|
|
1529
|
+
console.log(
|
|
1530
|
+
JSON.stringify(
|
|
1531
|
+
{
|
|
1532
|
+
schemaVersion: 1,
|
|
1533
|
+
command: 'run',
|
|
1534
|
+
adapter,
|
|
1535
|
+
status: result.status,
|
|
1536
|
+
exitCode,
|
|
1537
|
+
recovered: state.recovered,
|
|
1538
|
+
mutations: state.mutations,
|
|
1539
|
+
result,
|
|
1540
|
+
},
|
|
1541
|
+
null,
|
|
1542
|
+
2,
|
|
1543
|
+
),
|
|
1544
|
+
);
|
|
1545
|
+
} else {
|
|
1546
|
+
console.log(`MetaMask recipe run: ${result.status}\nArtifacts: ${result.artifactManifestPath}`);
|
|
1547
|
+
}
|
|
1548
|
+
return exitCode;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Usage error for the `run` execute path (recipe missing / unparseable — exit 2).
|
|
1552
|
+
// Mirrors the plan emitter but carries the run envelope keys, not `mode: 'plan'`.
|
|
1553
|
+
function emitRunUsageError(
|
|
1554
|
+
json: boolean,
|
|
1555
|
+
adapter: MetaMaskRecipeAdapter,
|
|
1556
|
+
recipeFile: string,
|
|
1557
|
+
code: string,
|
|
1558
|
+
message: string,
|
|
1559
|
+
): number {
|
|
1560
|
+
if (json) {
|
|
1561
|
+
console.log(
|
|
1562
|
+
JSON.stringify(
|
|
1563
|
+
{ schemaVersion: 1, command: 'run', adapter, status: 'fail', exitCode: EXIT.usage, recipe: recipeFile, error: { code, message } },
|
|
1564
|
+
null,
|
|
1565
|
+
2,
|
|
1566
|
+
),
|
|
1567
|
+
);
|
|
1568
|
+
} else {
|
|
1569
|
+
console.error(`✗ run: ${message}`);
|
|
1570
|
+
}
|
|
1571
|
+
return EXIT.usage;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Validation failure for the `run` execute path — exit 5 with the structured
|
|
1575
|
+
// finding list, before the app is launched or the recipe executes (parity with
|
|
1576
|
+
// `run --plan` and `call`).
|
|
1577
|
+
function emitRunValidationError(
|
|
1578
|
+
json: boolean,
|
|
1579
|
+
adapter: MetaMaskRecipeAdapter,
|
|
1580
|
+
recipeFile: string,
|
|
1581
|
+
findings: RecipeValidationFinding[],
|
|
1582
|
+
errorCount: number,
|
|
1583
|
+
): number {
|
|
1584
|
+
const message = `recipe validation found ${errorCount} error(s)`;
|
|
1585
|
+
if (json) {
|
|
1586
|
+
console.log(
|
|
1587
|
+
JSON.stringify(
|
|
1588
|
+
{
|
|
1589
|
+
schemaVersion: 1,
|
|
1590
|
+
command: 'run',
|
|
1591
|
+
adapter,
|
|
1592
|
+
status: 'fail',
|
|
1593
|
+
exitCode: EXIT.validation,
|
|
1594
|
+
recovered: [],
|
|
1595
|
+
mutations: [],
|
|
1596
|
+
recipe: recipeFile,
|
|
1597
|
+
findings,
|
|
1598
|
+
error: { code: 'RECIPE_VALIDATION_FAILED', message },
|
|
1599
|
+
},
|
|
1600
|
+
null,
|
|
1601
|
+
2,
|
|
1602
|
+
),
|
|
1603
|
+
);
|
|
1604
|
+
} else {
|
|
1605
|
+
console.error(`✗ run: ${message}`);
|
|
1606
|
+
for (const finding of findings) {
|
|
1607
|
+
if (finding.severity === 'error') console.error(` ${finding.code} ${finding.path} — ${finding.message}`);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return EXIT.validation;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// Shared failure emitter for the run/call heal path — surfaces the bound-specific
|
|
1614
|
+
// error code, the classification note, and the ORIGINAL failure verbatim in both
|
|
1615
|
+
// --json and human modes.
|
|
1616
|
+
function emitHealViolation(
|
|
1617
|
+
json: boolean,
|
|
1618
|
+
command: 'run' | 'call',
|
|
1619
|
+
result: RecipeRunResult,
|
|
1620
|
+
violation: HealBoundViolation,
|
|
1621
|
+
state: HealState,
|
|
1622
|
+
): number {
|
|
1623
|
+
if (json) {
|
|
1624
|
+
console.log(
|
|
1625
|
+
JSON.stringify(
|
|
1626
|
+
{
|
|
1627
|
+
schemaVersion: 1,
|
|
1628
|
+
command,
|
|
1629
|
+
status: 'fail',
|
|
1630
|
+
recoverable: false,
|
|
1631
|
+
recovered: state.recovered,
|
|
1632
|
+
mutations: state.mutations,
|
|
1633
|
+
attemptedRecoveries: state.attemptedRecoveries,
|
|
1634
|
+
summaryPath: result.summaryPath,
|
|
1635
|
+
tracePath: result.tracePath,
|
|
1636
|
+
artifactManifestPath: result.artifactManifestPath,
|
|
1637
|
+
exitCode: violation.exitCode,
|
|
1638
|
+
error: {
|
|
1639
|
+
code: violation.code,
|
|
1640
|
+
message: violation.message,
|
|
1641
|
+
retryable: false,
|
|
1642
|
+
userAction: violation.userAction ?? null,
|
|
1643
|
+
originalError: violation.originalError ?? null,
|
|
1644
|
+
},
|
|
1645
|
+
},
|
|
1646
|
+
null,
|
|
1647
|
+
2,
|
|
1648
|
+
),
|
|
1649
|
+
);
|
|
1650
|
+
} else {
|
|
1651
|
+
console.error(
|
|
1652
|
+
`✗ mm-harness ${command}: ${violation.message}` +
|
|
1653
|
+
(violation.originalError ? `\n --- original failure ---\n${violation.originalError}` : '') +
|
|
1654
|
+
(violation.userAction ? `\n Next: ${violation.userAction}` : ''),
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
return violation.exitCode;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
async function handleSelfTest({ options }: ParsedArgs): Promise<number> {
|
|
1661
|
+
const result = await runSelfTest(options);
|
|
1662
|
+
if (optionFlag(options, 'json')) console.log(JSON.stringify(result, null, 2));
|
|
1663
|
+
else console.log(`MetaMask runner self-test: ${result.status}\nArtifacts: ${result.artifactsDir}`);
|
|
1664
|
+
return result.status === 'pass' ? 0 : 1;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function runtimeOptionsFromCli(options: CliOptions): RuntimeOptions {
|
|
1668
|
+
const recordVideo = options.recordVideo;
|
|
1669
|
+
return {
|
|
1670
|
+
cdpPort: optionString(options, 'cdpPort'),
|
|
1671
|
+
watcherPort: optionString(options, 'watcherPort') ?? optionString(options, 'metroPort'),
|
|
1672
|
+
launchExistingDist: optionFlag(options, 'launchExistingDist'),
|
|
1673
|
+
slot: optionString(options, 'slot'),
|
|
1674
|
+
validationRuntimeDir: optionString(options, 'validationRuntimeDir'),
|
|
1675
|
+
recordVideo: recordVideo === 'full-run' ? 'full-run' : false,
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function parsePort(value: string | undefined, errorMessage: string): number {
|
|
1680
|
+
const port = Number(value);
|
|
1681
|
+
if (!Number.isInteger(port) || port <= 0) throw usageError(errorMessage);
|
|
1682
|
+
return port;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Resolve the ordered library sources for a run: the developer's explicit
|
|
1687
|
+
* entries (or their personal library) first, then this runner's canonical
|
|
1688
|
+
* `library/` appended last so personal/team flows win by default. Returns
|
|
1689
|
+
* undefined when the installed harness predates recipe libraries.
|
|
1690
|
+
*/
|
|
1691
|
+
async function resolveMetaMaskLibrarySources(
|
|
1692
|
+
libraryEntry: string | undefined,
|
|
1693
|
+
): Promise<MetaMaskLibrarySource[] | undefined> {
|
|
1694
|
+
const harness = (await importRecipeHarness()) as RecipeLibraryCapableHarness;
|
|
1695
|
+
if (typeof harness.resolveRecipeLibrarySources !== 'function') {
|
|
1696
|
+
if (libraryEntry) {
|
|
1697
|
+
throw usageError(
|
|
1698
|
+
'--library requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support / resolveRecipeLibrarySources). ' +
|
|
1699
|
+
'The pinned 0.3.0 lacks it, and npm 0.3.2 still does not export it — recipe-library support is PENDING PUBLISH from farmslot.',
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
return undefined;
|
|
1703
|
+
}
|
|
1704
|
+
const sources = await harness.resolveRecipeLibrarySources(
|
|
1705
|
+
libraryEntry ? { cliEntries: [libraryEntry] } : undefined,
|
|
1706
|
+
);
|
|
1707
|
+
sources.push({ name: 'metamask', root: path.join(runnerDir, 'library') });
|
|
1708
|
+
return sources;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function serializeLibrarySources(sources: MetaMaskLibrarySource[]): string {
|
|
1712
|
+
return sources
|
|
1713
|
+
.map((source) => (source.name ? `${source.name}=${source.root}` : source.root))
|
|
1714
|
+
.join(':');
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// The engine's flows subcommands (grounded in registerFlowsCommand): a bare
|
|
1718
|
+
// `flows` (or one that leads with a flag) means list.
|
|
1719
|
+
const FLOWS_SUBCOMMANDS: readonly string[] = ['list', 'promote'];
|
|
1720
|
+
|
|
1721
|
+
// --target is a runner-level flag (checkout selection); the engine's flows
|
|
1722
|
+
// subcommands do not accept it and flows resolution is adapter-global (the
|
|
1723
|
+
// canonical MetaMask library + the personal library), so it has no effect here.
|
|
1724
|
+
// Drop it (with its value) before forwarding so the engine never sees an unknown
|
|
1725
|
+
// option. Every other flag/positional forwards verbatim.
|
|
1726
|
+
function stripTargetFlag(argv: string[]): string[] {
|
|
1727
|
+
const out: string[] = [];
|
|
1728
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1729
|
+
const arg = argv[i];
|
|
1730
|
+
if (arg === '--target') {
|
|
1731
|
+
i += 1; // skip the value too
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
if (arg.startsWith('--target=')) continue;
|
|
1735
|
+
out.push(arg);
|
|
1736
|
+
}
|
|
1737
|
+
return out;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// flows delegates to the harness CLI with the resolved MetaMask default sources
|
|
1741
|
+
// exported through RECIPE_LIBRARY_PATH, so list/promote behave exactly like a
|
|
1742
|
+
// run's resolution.
|
|
1743
|
+
async function handleFlows(argv: string[]): Promise<number> {
|
|
1744
|
+
const sources = await resolveMetaMaskLibrarySources(undefined);
|
|
1745
|
+
if (!sources) {
|
|
1746
|
+
throw usageError(
|
|
1747
|
+
'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support). ' +
|
|
1748
|
+
'Next: update the dependency and run yarn install.',
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
const forwarded = stripTargetFlag(argv);
|
|
1752
|
+
// The engine requires an explicit subcommand. Inspect only the FIRST token: a
|
|
1753
|
+
// real subcommand there is passed through; anything else (empty, or a leading
|
|
1754
|
+
// flag whose VALUE must not be mistaken for a subcommand) defaults to list.
|
|
1755
|
+
const withSubcommand = FLOWS_SUBCOMMANDS.includes(forwarded[0])
|
|
1756
|
+
? forwarded
|
|
1757
|
+
: ['list', ...forwarded];
|
|
1758
|
+
const { runRecipeHarnessCli } = await importRecipeHarnessCli();
|
|
1759
|
+
const previousLibraryPath = process.env.RECIPE_LIBRARY_PATH;
|
|
1760
|
+
process.env.RECIPE_LIBRARY_PATH = serializeLibrarySources(sources);
|
|
1761
|
+
try {
|
|
1762
|
+
await runRecipeHarnessCli(['flows', ...withSubcommand]);
|
|
1763
|
+
} finally {
|
|
1764
|
+
restoreEnv('RECIPE_LIBRARY_PATH', previousLibraryPath);
|
|
1765
|
+
}
|
|
1766
|
+
return 0;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
export async function main(argv: string[]): Promise<number> {
|
|
1770
|
+
const command = argv[0];
|
|
1771
|
+
if (!command || command === '-h' || command === '--help') {
|
|
1772
|
+
usage();
|
|
1773
|
+
return command ? 0 : 2;
|
|
1774
|
+
}
|
|
1775
|
+
if (command === 'flows') return handleFlows(argv.slice(1));
|
|
1776
|
+
// call + completion-candidates parse repeatable/positional args of their own, so
|
|
1777
|
+
// they take raw argv rather than the shared Record-based parseArgs.
|
|
1778
|
+
if (command === 'call') return handleCall(argv.slice(1));
|
|
1779
|
+
if (command === 'completion-candidates') return handleCompletionCandidates(argv.slice(1));
|
|
1780
|
+
// Top-level overlay commands route to handleHarness; the command itself is the
|
|
1781
|
+
// action, so forward argv unchanged.
|
|
1782
|
+
if (OVERLAY_COMMANDS.includes(command)) {
|
|
1783
|
+
const code = await handleHarness(argv);
|
|
1784
|
+
// The overlay defines the action/flow vocabulary — a successful install
|
|
1785
|
+
// invalidates the per-checkout completion cache (dynamic-completions contract).
|
|
1786
|
+
if (code === 0 && command === 'install') {
|
|
1787
|
+
try {
|
|
1788
|
+
invalidateCompletionCache(targetPath(parseArgs(argv.slice(1), command).options));
|
|
1789
|
+
} catch {
|
|
1790
|
+
// Cache invalidation is best-effort; never fail an install on it.
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return code;
|
|
1794
|
+
}
|
|
1795
|
+
// launch/logs/debug/fixtures compose the porcelain paths and own policy,
|
|
1796
|
+
// healing, teaching, and the --json contract.
|
|
1797
|
+
if (command === 'launch') return handleLaunch(argv.slice(1));
|
|
1798
|
+
if (command === 'logs') return handleLogs(argv.slice(1));
|
|
1799
|
+
if (command === 'debug') return handleDebug(argv.slice(1));
|
|
1800
|
+
if (command === 'fixtures') return handleFixtures(argv.slice(1), { runOneNode });
|
|
1801
|
+
const handler = COMMANDS[command];
|
|
1802
|
+
if (!handler) throw new Error(`Unknown command: ${command}`);
|
|
1803
|
+
return handler(parseArgs(argv.slice(1), command));
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Auto-run only when this file IS the process entry (orchestration/porcelain/metamask-recipe
|
|
1807
|
+
// execs it directly). The commander front (`runner/src/mm-harness-cli.ts`) sets this flag
|
|
1808
|
+
// before importing so it can reuse `main` without triggering a second dispatch.
|
|
1809
|
+
if (!(globalThis as Record<string, unknown>).__MM_HARNESS_WRAPPER__) {
|
|
1810
|
+
try {
|
|
1811
|
+
process.exit(await main(process.argv.slice(2)));
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1814
|
+
process.exit(
|
|
1815
|
+
error !== null && typeof error === 'object' && 'exitCode' in error && typeof (error as Record<string, unknown>).exitCode === 'number'
|
|
1816
|
+
? (error as { exitCode: number }).exitCode
|
|
1817
|
+
: 1,
|
|
1818
|
+
);
|
|
1819
|
+
}
|
|
1820
|
+
}
|