@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,516 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from './paths.ts';
|
|
6
|
+
import type { MetaMaskRecipeAdapter } from './types.ts';
|
|
7
|
+
|
|
8
|
+
// The `harness` command moves the recipe-harness skill's runner-locate + dispatch
|
|
9
|
+
// bash (resolve-runner-source.sh + recipe-harness.sh dispatch_adapter_action)
|
|
10
|
+
// into typed runner code. The skill resolved an *external* runner source; inside
|
|
11
|
+
// the runner the root is known, so "resolve runner root" collapses to runnerDir.
|
|
12
|
+
// Everything else — the per-subcommand/platform orchestration file mapping,
|
|
13
|
+
// candidate search order, installed-overlay/installed-script preference, the
|
|
14
|
+
// core delegate, extension runtime-context env, and verbatim arg passthrough —
|
|
15
|
+
// is reproduced exactly so the skill can become a thin caller.
|
|
16
|
+
|
|
17
|
+
type HarnessAction = 'install' | 'verify' | 'cleanup' | 'live';
|
|
18
|
+
|
|
19
|
+
const HARNESS_ACTIONS: readonly HarnessAction[] = ['install', 'verify', 'cleanup', 'live'];
|
|
20
|
+
const ADAPTERS: readonly MetaMaskRecipeAdapter[] = ['mobile', 'extension', 'core'];
|
|
21
|
+
|
|
22
|
+
// Mirror of scripts/lib/cli-common.sh valid_adapter_action, restricted to the
|
|
23
|
+
// subcommands this command exposes (core has no app/live lifecycle).
|
|
24
|
+
function isValidAdapterAction(adapter: MetaMaskRecipeAdapter, action: HarnessAction): boolean {
|
|
25
|
+
if (adapter === 'core') return action === 'install' || action === 'verify' || action === 'cleanup';
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function harnessUsage(): void {
|
|
30
|
+
console.error(`mm-harness — install and validate the MetaMask recipe runtime in a checkout.
|
|
31
|
+
|
|
32
|
+
Run it from inside a MetaMask checkout; the platform (mobile | extension | core)
|
|
33
|
+
is auto-detected from the repo. Pass --platform only to override.
|
|
34
|
+
|
|
35
|
+
Commands (one copy-pasteable example each):
|
|
36
|
+
install Install the recipe harness runtime overlay into the checkout.
|
|
37
|
+
mm-harness install
|
|
38
|
+
verify Check the harness/runtime is present and healthy (no app launch).
|
|
39
|
+
mm-harness verify
|
|
40
|
+
cleanup Remove the installed harness overlay and restore the checkout.
|
|
41
|
+
mm-harness cleanup
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--platform <mobile|extension|core> Override auto-detection (alias: --adapter).
|
|
45
|
+
--target <repo> Checkout to operate on (default: current dir).
|
|
46
|
+
--json Machine-readable summary for agents/scripts.
|
|
47
|
+
-- <args> Forward the rest verbatim to the underlying
|
|
48
|
+
script, e.g. -- --platform ios --preflight-mode fast
|
|
49
|
+
(mobile) or -- --cdp-port 6665 (extension).
|
|
50
|
+
|
|
51
|
+
Examples:
|
|
52
|
+
mm-harness install # inside a checkout, auto-detected
|
|
53
|
+
mm-harness verify --platform extension
|
|
54
|
+
mm-harness cleanup --target ../metamask-mobile
|
|
55
|
+
|
|
56
|
+
core supports install, verify, and cleanup (headless; no live).`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function failureHint(adapter: MetaMaskRecipeAdapter, action: HarnessAction): string {
|
|
60
|
+
if (adapter === 'core') {
|
|
61
|
+
if (action === 'verify') {
|
|
62
|
+
return `Install the core harness first: mm-harness install --platform core`;
|
|
63
|
+
}
|
|
64
|
+
return 'Read the error above for the specific cause, then re-run this command.';
|
|
65
|
+
}
|
|
66
|
+
const helper = adapter === 'mobile' ? 'mm-harness launch ios # or launch android' : 'mm-harness launch';
|
|
67
|
+
return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// has_arg: matches an exact flag or its `flag=value` form.
|
|
71
|
+
function hasArg(args: string[], needle: string): boolean {
|
|
72
|
+
return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// arg_value: value for `flag value` or `flag=value`, else undefined.
|
|
76
|
+
function argValue(args: string[], needle: string): string | undefined {
|
|
77
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
78
|
+
if (args[i] === needle) return args[i + 1];
|
|
79
|
+
if (args[i].startsWith(`${needle}=`)) return args[i].slice(needle.length + 1);
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isAdapter(value: string | undefined): value is MetaMaskRecipeAdapter {
|
|
85
|
+
return value === 'mobile' || value === 'extension' || value === 'core';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ParsedHarnessArgs {
|
|
89
|
+
// Kept as raw string so invalid values (e.g. --adapter garbage) are not silently
|
|
90
|
+
// cast to MetaMaskRecipeAdapter; narrowed via isAdapter() at the validation site.
|
|
91
|
+
adapter?: string;
|
|
92
|
+
json: boolean;
|
|
93
|
+
forward: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Split the adapter selector and --json out of the remaining argv so they are not
|
|
97
|
+
// forwarded to the orchestration script (the .sh/.mjs scripts reject unknown
|
|
98
|
+
// flags; Mobile's live/verify use --platform ios|android, so only the first
|
|
99
|
+
// --platform whose value is an adapter is consumed here and a later --platform is
|
|
100
|
+
// passed through). `--` forces everything after it to passthrough.
|
|
101
|
+
function parseHarnessArgs(args: string[]): ParsedHarnessArgs {
|
|
102
|
+
const forward: string[] = [];
|
|
103
|
+
let adapter: string | undefined;
|
|
104
|
+
let json = false;
|
|
105
|
+
let separator = false;
|
|
106
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
107
|
+
const arg = args[i];
|
|
108
|
+
// POSIX: everything after `--` is literal; check separator FIRST so that
|
|
109
|
+
// --json appearing after `--` is forwarded verbatim to the delegate.
|
|
110
|
+
if (separator) {
|
|
111
|
+
forward.push(arg);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (arg === '--') {
|
|
115
|
+
separator = true;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (arg === '--json') {
|
|
119
|
+
json = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (arg === '--adapter' || arg === '--platform') {
|
|
123
|
+
const value = args[i + 1];
|
|
124
|
+
if (arg === '--adapter') {
|
|
125
|
+
adapter = value;
|
|
126
|
+
i += 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (adapter === undefined && isAdapter(value)) {
|
|
130
|
+
adapter = value;
|
|
131
|
+
i += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
forward.push(arg);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (arg.startsWith('--adapter=')) {
|
|
138
|
+
adapter = arg.slice('--adapter='.length);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (arg.startsWith('--platform=')) {
|
|
142
|
+
const value = arg.slice('--platform='.length);
|
|
143
|
+
if (adapter === undefined && isAdapter(value)) {
|
|
144
|
+
adapter = value;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
forward.push(arg);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
forward.push(arg);
|
|
151
|
+
}
|
|
152
|
+
return { adapter, json, forward };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Mirror of scripts/lib/cli-common.sh detect_adapter for the auto-detect path.
|
|
156
|
+
// Exported so `doctor` shares the exact same detect-from-target logic as the
|
|
157
|
+
// overlay commands (verify/install/cleanup) — one auto-detect story, no drift.
|
|
158
|
+
export function detectAdapter(target: string): MetaMaskRecipeAdapter | undefined {
|
|
159
|
+
let remote = '';
|
|
160
|
+
try {
|
|
161
|
+
remote = execFileSync('git', ['-C', target, 'config', '--get', 'remote.origin.url'], {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
164
|
+
}).trim();
|
|
165
|
+
} catch {
|
|
166
|
+
remote = '';
|
|
167
|
+
}
|
|
168
|
+
if (remote.includes('metamask-extension')) return 'extension';
|
|
169
|
+
if (remote.includes('metamask-mobile')) return 'mobile';
|
|
170
|
+
|
|
171
|
+
const exists = (rel: string): boolean => fs.existsSync(path.join(target, rel));
|
|
172
|
+
const isDir = (rel: string): boolean => {
|
|
173
|
+
try {
|
|
174
|
+
return fs.statSync(path.join(target, rel)).isDirectory();
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (exists('development/skills-sync.ts') || (isDir('ui') && isDir('app/scripts'))) return 'extension';
|
|
181
|
+
if (exists('scripts/skills-sync.mts') || (isDir('ios') && isDir('android') && isDir('app/core'))) return 'mobile';
|
|
182
|
+
if (isDir('packages/perps-controller') && exists('yarn.lock')) return 'core';
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Resolve the agentic-runtime.json path for a checkout: the RECIPE_RUNTIME_CONTEXT
|
|
187
|
+
// override wins, else the per-checkout runtime dir. Single source shared by the
|
|
188
|
+
// extension pre-dispatch hydration and doctor's runtime-context report.
|
|
189
|
+
export function resolveRuntimeContextPath(target: string): string {
|
|
190
|
+
return process.env.RECIPE_RUNTIME_CONTEXT ?? path.join(target, recipeRuntimeDir(), 'agentic-runtime.json');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// read_runtime_context_field: dotted lookup returning the value only when it is a
|
|
194
|
+
// present, non-empty scalar; undefined when the file/field is missing.
|
|
195
|
+
export function readRuntimeContextField(contextPath: string, field: string): string | undefined {
|
|
196
|
+
let data: unknown;
|
|
197
|
+
try {
|
|
198
|
+
data = JSON.parse(fs.readFileSync(contextPath, 'utf8'));
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
let node: unknown = data;
|
|
203
|
+
for (const key of field.split('.')) {
|
|
204
|
+
if (node === undefined || node === null || typeof node !== 'object') return undefined;
|
|
205
|
+
node = (node as Record<string, unknown>)[key];
|
|
206
|
+
}
|
|
207
|
+
if (node === undefined || node === null || node === '') return undefined;
|
|
208
|
+
return String(node);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Port of recipe-harness.sh's extension pre-dispatch block: hydrate runtime env
|
|
212
|
+
// from agentic-runtime.json and append --cdp-port / --prepare-cmd to the args
|
|
213
|
+
// exactly as the skill did (extension launch/live/verify; here live/verify).
|
|
214
|
+
function applyExtensionRuntimeEnv(target: string, action: HarnessAction, args: string[]): string[] {
|
|
215
|
+
const contextPath = resolveRuntimeContextPath(target);
|
|
216
|
+
const contextExists = fs.existsSync(contextPath);
|
|
217
|
+
|
|
218
|
+
if (contextExists) {
|
|
219
|
+
process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
|
|
220
|
+
if (!process.env.RECIPE_SLOT_ID) {
|
|
221
|
+
const slotId = readRuntimeContextField(contextPath, 'slotId');
|
|
222
|
+
if (slotId) process.env.RECIPE_SLOT_ID = slotId;
|
|
223
|
+
}
|
|
224
|
+
if (!process.env.RECIPE_HARNESS_EXTENSION_ID) {
|
|
225
|
+
const extensionId = readRuntimeContextField(contextPath, 'extensionId');
|
|
226
|
+
if (extensionId) process.env.RECIPE_HARNESS_EXTENSION_ID = extensionId;
|
|
227
|
+
}
|
|
228
|
+
if (process.env.RECIPE_RUNTIME_START_APPROVED === undefined) {
|
|
229
|
+
const approved = readRuntimeContextField(contextPath, 'runtimeStart.approved');
|
|
230
|
+
if (approved === 'true' || approved === 'True' || approved === '1') process.env.RECIPE_RUNTIME_START_APPROVED = '1';
|
|
231
|
+
else if (approved === 'false' || approved === 'False' || approved === '0') process.env.RECIPE_RUNTIME_START_APPROVED = '0';
|
|
232
|
+
}
|
|
233
|
+
if (!process.env.RECIPE_RUNTIME_START_CMD) {
|
|
234
|
+
const command = readRuntimeContextField(contextPath, 'runtimeStart.command');
|
|
235
|
+
if (command) process.env.RECIPE_RUNTIME_START_CMD = command;
|
|
236
|
+
}
|
|
237
|
+
if (!process.env.RECIPE_RUNTIME_READY_URL) {
|
|
238
|
+
const readyUrl = readRuntimeContextField(contextPath, 'runtimeStart.readyUrl');
|
|
239
|
+
if (readyUrl) process.env.RECIPE_RUNTIME_READY_URL = readyUrl;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let result = [...args];
|
|
244
|
+
if (!hasArg(result, '--cdp-port')) {
|
|
245
|
+
const contextPort = contextExists ? readRuntimeContextField(contextPath, 'cdpPort') : undefined;
|
|
246
|
+
const cdpPort = contextPort ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
|
|
247
|
+
if (cdpPort) {
|
|
248
|
+
process.env.RECIPE_CDP_PORT = cdpPort;
|
|
249
|
+
process.env.CDP_PORT = cdpPort;
|
|
250
|
+
result = [...result, '--cdp-port', cdpPort];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (action === 'live' && !hasArg(result, '--prepare-cmd')) {
|
|
254
|
+
if (process.env.RECIPE_RUNTIME_START_APPROVED === '1' && process.env.RECIPE_RUNTIME_START_CMD) {
|
|
255
|
+
result = [...result, '--prepare-cmd', process.env.RECIPE_RUNTIME_START_CMD];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// runner_executable_entry / runner_file_entry: first candidate that exists (as an
|
|
262
|
+
// executable, or as any file) wins; otherwise the first candidate is returned so
|
|
263
|
+
// the exec error names it.
|
|
264
|
+
function resolveEntry(base: string, candidates: string[], mode: 'exec' | 'file'): string {
|
|
265
|
+
for (const candidate of candidates) {
|
|
266
|
+
const full = path.join(base, candidate);
|
|
267
|
+
try {
|
|
268
|
+
const stat = fs.statSync(full);
|
|
269
|
+
if (!stat.isFile()) continue;
|
|
270
|
+
if (mode === 'file' || (stat.mode & 0o111) !== 0) return full;
|
|
271
|
+
} catch {
|
|
272
|
+
// missing candidate; try the next one
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return path.join(base, candidates[0]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Content of an installed overlay's runner source pointer, if present. Command
|
|
279
|
+
// substitution in the skill stripped trailing newlines, so trim here too.
|
|
280
|
+
function installedRunnerSource(target: string, adapter: MetaMaskRecipeAdapter): string | undefined {
|
|
281
|
+
const pointer = path.join(recipeHarnessPath(target, adapter), 'runner', '.runner-source');
|
|
282
|
+
if (!fs.existsSync(pointer)) return undefined;
|
|
283
|
+
const value = fs.readFileSync(pointer, 'utf8').trim();
|
|
284
|
+
return value || undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isExecutable(file: string): boolean {
|
|
288
|
+
try {
|
|
289
|
+
const stat = fs.statSync(file);
|
|
290
|
+
return stat.isFile() && (stat.mode & 0o111) !== 0;
|
|
291
|
+
} catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface HarnessDispatch {
|
|
297
|
+
command: string;
|
|
298
|
+
prefixArgs: string[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const INJECT_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
|
|
302
|
+
mobile: { entry: 'orchestration/mobile/inject.sh', fallback: 'scripts/inject-mobile-harness.sh' },
|
|
303
|
+
extension: { entry: 'orchestration/extension/inject.mjs', fallback: 'scripts/inject-extension-harness.mjs' },
|
|
304
|
+
core: { entry: 'orchestration/core/inject.sh', fallback: 'scripts/inject-core-harness.sh' },
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const CLEANUP_CANDIDATES: Record<MetaMaskRecipeAdapter, { entry: string; fallback: string }> = {
|
|
308
|
+
mobile: { entry: 'orchestration/mobile/cleanup.sh', fallback: 'scripts/cleanup-mobile-harness.sh' },
|
|
309
|
+
extension: { entry: 'orchestration/extension/cleanup.mjs', fallback: 'scripts/cleanup-extension-harness.mjs' },
|
|
310
|
+
core: { entry: 'orchestration/core/cleanup.sh', fallback: 'scripts/cleanup-core-harness.sh' },
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Resolve the exact command + fixed prefix args to exec, mirroring
|
|
314
|
+
// recipe-harness.sh dispatch_adapter_action. forwardArgs are appended by the
|
|
315
|
+
// caller. Returns a string message for the one fail-fast case (missing core
|
|
316
|
+
// delegate) that the skill handled inline.
|
|
317
|
+
function resolveHarnessDispatch(
|
|
318
|
+
adapter: MetaMaskRecipeAdapter,
|
|
319
|
+
action: HarnessAction,
|
|
320
|
+
target: string,
|
|
321
|
+
): HarnessDispatch | { error: string } {
|
|
322
|
+
if (action === 'install') {
|
|
323
|
+
const { entry, fallback } = INJECT_CANDIDATES[adapter];
|
|
324
|
+
if (adapter === 'extension') {
|
|
325
|
+
return { command: process.execPath, prefixArgs: [resolveEntry(runnerDir, [entry, fallback], 'file')] };
|
|
326
|
+
}
|
|
327
|
+
return { command: resolveEntry(runnerDir, [entry, fallback], 'exec'), prefixArgs: [] };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (action === 'cleanup') {
|
|
331
|
+
const { entry, fallback } = CLEANUP_CANDIDATES[adapter];
|
|
332
|
+
const base = installedRunnerSource(target, adapter) ?? runnerDir;
|
|
333
|
+
if (adapter === 'extension') {
|
|
334
|
+
return { command: process.execPath, prefixArgs: [resolveEntry(base, [entry, fallback], 'file')] };
|
|
335
|
+
}
|
|
336
|
+
return { command: resolveEntry(base, [entry, fallback], 'exec'), prefixArgs: [] };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (adapter === 'core' && action === 'verify') {
|
|
340
|
+
const delegate = path.join(recipeHarnessPath(target, 'core'), 'runner', 'bin', 'metamask-recipe');
|
|
341
|
+
if (!isExecutable(delegate)) {
|
|
342
|
+
return {
|
|
343
|
+
error:
|
|
344
|
+
`core recipe harness not installed: missing delegate ${delegate}.\n` +
|
|
345
|
+
`Next: mm-harness install --platform core --target ${target}`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// --target is carried in forwardArgs (injected when the caller omits it), so
|
|
349
|
+
// the delegate still receives `doctor --adapter core --target <t>`.
|
|
350
|
+
return { command: delegate, prefixArgs: ['doctor', '--adapter', 'core'] };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// mobile/extension live|verify: prefer the installed helper, else the runner's
|
|
354
|
+
// own orchestration/runner/scripts helper (first executable candidate wins).
|
|
355
|
+
const installedScript = path.join(recipeHarnessPath(target, adapter), 'scripts', `${action}.sh`);
|
|
356
|
+
if (isExecutable(installedScript)) {
|
|
357
|
+
return { command: installedScript, prefixArgs: [] };
|
|
358
|
+
}
|
|
359
|
+
const command = resolveEntry(
|
|
360
|
+
runnerDir,
|
|
361
|
+
[`orchestration/${adapter}/${action}.sh`, `runner/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
|
|
362
|
+
'exec',
|
|
363
|
+
);
|
|
364
|
+
return { command, prefixArgs: [] };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function handleHarness(argv: string[]): Promise<number> {
|
|
368
|
+
const action = argv[0];
|
|
369
|
+
if (!action || action === '-h' || action === '--help') {
|
|
370
|
+
harnessUsage();
|
|
371
|
+
return action ? 0 : 2;
|
|
372
|
+
}
|
|
373
|
+
if (!HARNESS_ACTIONS.includes(action as HarnessAction)) {
|
|
374
|
+
harnessUsage();
|
|
375
|
+
console.error(`unsupported harness subcommand: ${action}`);
|
|
376
|
+
return 2;
|
|
377
|
+
}
|
|
378
|
+
const harnessAction = action as HarnessAction;
|
|
379
|
+
|
|
380
|
+
const { adapter: parsedAdapter, json, forward } = parseHarnessArgs(argv.slice(1));
|
|
381
|
+
const rawTarget = argValue(forward, '--target');
|
|
382
|
+
const target = path.resolve(rawTarget ?? process.cwd());
|
|
383
|
+
|
|
384
|
+
const adapter = parsedAdapter ?? detectAdapter(target);
|
|
385
|
+
// isAdapter() narrows adapter to MetaMaskRecipeAdapter for all code below.
|
|
386
|
+
if (!adapter || !isAdapter(adapter)) {
|
|
387
|
+
if (json) {
|
|
388
|
+
// parsedAdapter is string|undefined here; not yet validated as a real
|
|
389
|
+
// adapter, so pass undefined rather than forwarding a garbage string.
|
|
390
|
+
const detectError = adapter
|
|
391
|
+
? { code: 'UNSUPPORTED_PLATFORM', message: `unsupported platform: ${adapter}` }
|
|
392
|
+
: { code: 'ADAPTER_DETECTION_FAILED', message: `could not detect the MetaMask repo type for ${target}` };
|
|
393
|
+
console.log(harnessSummary(harnessAction, undefined, target, 'fail', 2, false, detectError));
|
|
394
|
+
} else {
|
|
395
|
+
harnessUsage();
|
|
396
|
+
console.error(
|
|
397
|
+
adapter
|
|
398
|
+
? `\n✗ unsupported platform: ${adapter}`
|
|
399
|
+
: `\n✗ could not detect the MetaMask repo type for ${target}\n Next: pass --platform mobile|extension|core`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return 2;
|
|
403
|
+
}
|
|
404
|
+
if (!isValidAdapterAction(adapter, harnessAction)) {
|
|
405
|
+
if (json) {
|
|
406
|
+
console.log(
|
|
407
|
+
harnessSummary(harnessAction, adapter, target, 'fail', 2, parsedAdapter === undefined, {
|
|
408
|
+
code: 'UNSUPPORTED_ACTION',
|
|
409
|
+
message: `${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
} else {
|
|
413
|
+
console.error(
|
|
414
|
+
`✗ ${harnessAction} is not supported for ${adapter} (core supports install, verify, cleanup).`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
return 2;
|
|
418
|
+
}
|
|
419
|
+
const autoDetected = parsedAdapter === undefined;
|
|
420
|
+
|
|
421
|
+
// Forward args carry --target so the orchestration script resolves the same
|
|
422
|
+
// checkout; inject the resolved path only when the caller omitted it.
|
|
423
|
+
let forwardArgs = hasArg(forward, '--target') ? [...forward] : ['--target', target, ...forward];
|
|
424
|
+
if (adapter === 'extension' && (harnessAction === 'live' || harnessAction === 'verify')) {
|
|
425
|
+
forwardArgs = applyExtensionRuntimeEnv(target, harnessAction, forwardArgs);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const dispatch = resolveHarnessDispatch(adapter, harnessAction, target);
|
|
429
|
+
if ('error' in dispatch) {
|
|
430
|
+
if (json) {
|
|
431
|
+
console.log(
|
|
432
|
+
harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
|
|
433
|
+
code: 'DISPATCH_UNAVAILABLE',
|
|
434
|
+
message: dispatch.error,
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
} else {
|
|
438
|
+
console.error(`✗ ${harnessAction} ${adapter} failed\n ${dispatch.error.replace(/\n/gu, '\n ')}`);
|
|
439
|
+
}
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (!json) {
|
|
444
|
+
const detected = autoDetected ? ', auto-detected' : '';
|
|
445
|
+
console.error(`→ ${harnessAction} (${adapter}${detected}) — target: ${target}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const start = Date.now();
|
|
449
|
+
const result = spawnSync(dispatch.command, [...dispatch.prefixArgs, ...forwardArgs], {
|
|
450
|
+
// Default: stream child output verbatim (byte-identical to the skill path).
|
|
451
|
+
// --json: route child output to our stderr so stdout carries only the summary.
|
|
452
|
+
stdio: json ? ['inherit', 2, 'inherit'] : 'inherit',
|
|
453
|
+
env: process.env,
|
|
454
|
+
});
|
|
455
|
+
const seconds = ((Date.now() - start) / 1000).toFixed(1);
|
|
456
|
+
|
|
457
|
+
if (result.error) {
|
|
458
|
+
if (json) {
|
|
459
|
+
console.log(
|
|
460
|
+
harnessSummary(harnessAction, adapter, target, 'fail', 1, autoDetected, {
|
|
461
|
+
code: 'HARNESS_SPAWN_FAILED',
|
|
462
|
+
message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
|
|
463
|
+
}),
|
|
464
|
+
);
|
|
465
|
+
} else {
|
|
466
|
+
console.error(`✗ ${harnessAction} ${adapter} could not start: ${result.error.message}\n ${failureHint(adapter, harnessAction)}`);
|
|
467
|
+
}
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const exitCode = result.status ?? 1;
|
|
472
|
+
if (json) {
|
|
473
|
+
console.log(
|
|
474
|
+
harnessSummary(
|
|
475
|
+
harnessAction,
|
|
476
|
+
adapter,
|
|
477
|
+
target,
|
|
478
|
+
exitCode === 0 ? 'pass' : 'fail',
|
|
479
|
+
exitCode,
|
|
480
|
+
autoDetected,
|
|
481
|
+
exitCode === 0
|
|
482
|
+
? undefined
|
|
483
|
+
: { code: 'HARNESS_FAILED', message: `${harnessAction} ${adapter} failed (exit ${exitCode})` },
|
|
484
|
+
),
|
|
485
|
+
);
|
|
486
|
+
} else if (exitCode === 0) {
|
|
487
|
+
console.error(`✓ ${harnessAction} ${adapter} passed (${seconds}s)`);
|
|
488
|
+
} else {
|
|
489
|
+
console.error(`✗ ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)\n ${failureHint(adapter, harnessAction)}`);
|
|
490
|
+
}
|
|
491
|
+
return exitCode;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function harnessSummary(
|
|
495
|
+
action: HarnessAction,
|
|
496
|
+
adapter: MetaMaskRecipeAdapter | undefined,
|
|
497
|
+
target: string,
|
|
498
|
+
status: 'pass' | 'fail',
|
|
499
|
+
exitCode: number,
|
|
500
|
+
autoDetected: boolean,
|
|
501
|
+
error?: { code: string; message: string },
|
|
502
|
+
): string {
|
|
503
|
+
return JSON.stringify({
|
|
504
|
+
schemaVersion: 1,
|
|
505
|
+
command: 'harness',
|
|
506
|
+
action,
|
|
507
|
+
adapter: adapter ?? null,
|
|
508
|
+
target,
|
|
509
|
+
autoDetected,
|
|
510
|
+
status,
|
|
511
|
+
exitCode,
|
|
512
|
+
// Error contract: every --json failure carries a stable machine code + human
|
|
513
|
+
// message (CLI-SPEC.md §5.1).
|
|
514
|
+
...(status === 'fail' && error ? { error } : {}),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Healing policy + recovery bounds — shared by launch, run, and call (policy, not
|
|
2
|
+
// per-command). Owns: overlay auto-ensure, failure classification, the recovery
|
|
3
|
+
// bounds that stop blind retry loops, and the --heal parser.
|
|
4
|
+
//
|
|
5
|
+
// Overlay auto-ensure seam (overridable for CI/agents and contract tests):
|
|
6
|
+
// MM_HARNESS_INSTALL_BIN — overlay installer (default: handleHarness install)
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
|
|
11
|
+
import { handleHarness } from './harness.ts';
|
|
12
|
+
import { recipeHarnessPath, recipeRuntimePath } from './paths.ts';
|
|
13
|
+
import type { MetaMaskRecipeAdapter } from './types.ts';
|
|
14
|
+
import { EXIT } from './commands/shared.ts';
|
|
15
|
+
import type { DeviceAdapter } from './commands/shared.ts';
|
|
16
|
+
|
|
17
|
+
export type HealPolicy = 'off' | 'infra-only' | 'auto';
|
|
18
|
+
|
|
19
|
+
interface Mutation {
|
|
20
|
+
type: string;
|
|
21
|
+
action: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HealState {
|
|
26
|
+
recovered: string[];
|
|
27
|
+
mutations: Mutation[];
|
|
28
|
+
attemptedRecoveries: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function newHealState(): HealState {
|
|
32
|
+
return { recovered: [], mutations: [], attemptedRecoveries: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseHeal(
|
|
36
|
+
options: Record<string, string | boolean>,
|
|
37
|
+
fallback: HealPolicy,
|
|
38
|
+
): HealPolicy | { error: string } {
|
|
39
|
+
const value = options.heal;
|
|
40
|
+
if (value === undefined) return fallback;
|
|
41
|
+
if (value === 'off' || value === 'infra-only' || value === 'auto') return value;
|
|
42
|
+
return { error: `--heal must be off, infra-only, or auto (got "${String(value)}").` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function overlayDir(target: string, adapter: MetaMaskRecipeAdapter): string {
|
|
46
|
+
return recipeHarnessPath(target, adapter);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function overlayPresent(target: string, adapter: MetaMaskRecipeAdapter): boolean {
|
|
50
|
+
try {
|
|
51
|
+
return fs.statSync(overlayDir(target, adapter)).isDirectory();
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Auto-ensure the runtime overlay (install phase). Missing + heal != off →
|
|
58
|
+
// install inline, emit a first-install notice to stderr, and record the mutation.
|
|
59
|
+
// heal === off disables auto-install (repro-preserving); we proceed and let the
|
|
60
|
+
// porcelain surface any missing-overlay failure itself.
|
|
61
|
+
export async function ensureOverlay(
|
|
62
|
+
adapter: MetaMaskRecipeAdapter,
|
|
63
|
+
target: string,
|
|
64
|
+
heal: HealPolicy,
|
|
65
|
+
state: HealState,
|
|
66
|
+
json: boolean,
|
|
67
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
68
|
+
// core is headless — the engine path used by run/call needs no launch overlay.
|
|
69
|
+
if (adapter === 'core') return { ok: true };
|
|
70
|
+
if (overlayPresent(target, adapter)) return { ok: true };
|
|
71
|
+
if (heal === 'off') return { ok: true };
|
|
72
|
+
|
|
73
|
+
const installBin = process.env.MM_HARNESS_INSTALL_BIN;
|
|
74
|
+
let code: number;
|
|
75
|
+
if (installBin) {
|
|
76
|
+
const result = spawnSync(installBin, ['install', '--platform', adapter, '--target', target], {
|
|
77
|
+
cwd: target,
|
|
78
|
+
stdio: ['ignore', 2, 'inherit'],
|
|
79
|
+
env: process.env,
|
|
80
|
+
});
|
|
81
|
+
code = result.status ?? 1;
|
|
82
|
+
} else {
|
|
83
|
+
code = await handleHarness(['install', '--platform', adapter, '--target', target, ...(json ? ['--json'] : [])]);
|
|
84
|
+
}
|
|
85
|
+
if (code !== 0 || !overlayPresent(target, adapter)) {
|
|
86
|
+
return { ok: false, error: `runtime overlay install failed (exit ${code})` };
|
|
87
|
+
}
|
|
88
|
+
const dir = overlayDir(target, adapter);
|
|
89
|
+
state.mutations.push({ type: 'file', action: 'created', path: dir });
|
|
90
|
+
// First-install notice → stderr (human) / mutations[] only (json; stdout clean).
|
|
91
|
+
if (!json) process.stderr.write(`installed mm-harness overlay → ${dir}\n`);
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A recovery that would need a seeded wallet is NEVER auto-performed (healing
|
|
96
|
+
// never touches fixtures). Only POSITIVELY-IDENTIFIED infra transport failures
|
|
97
|
+
// are healable. Unknown/unclassified failures → 'app' (safe default: no healing
|
|
98
|
+
// attempted, failure surfaced verbatim). Defaulting to 'infra' would cause
|
|
99
|
+
// self-healing to mask real app-logic breakage.
|
|
100
|
+
export function classifyFailure(output: string): 'wallet' | 'infra' | 'app' {
|
|
101
|
+
if (/wallet|fixture|keyring|not seeded|\bsrp\b|password|onboard/iu.test(output)) return 'wallet';
|
|
102
|
+
if (/metro|cdp|chrome|bundle|packager|port\b|econnrefused|not reachable|watcher|dev client|websocket/iu.test(output)) {
|
|
103
|
+
return 'infra';
|
|
104
|
+
}
|
|
105
|
+
return 'app';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Refuse ALL recovery while a recipe is executing (mid-run recovery would corrupt
|
|
109
|
+
// state). Signalled by a lock file or MM_HARNESS_RECIPE_RUNNING=1.
|
|
110
|
+
export function recipeRunning(target: string): boolean {
|
|
111
|
+
if (process.env.MM_HARNESS_RECIPE_RUNNING === '1') return true;
|
|
112
|
+
return fs.existsSync(recipeRuntimePath(target, 'recipe.lock'));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Stable recovery codes for the launch infra retry (surfaced in recovered[]).
|
|
116
|
+
export const RECOVERY_CODE: Record<DeviceAdapter, string> = {
|
|
117
|
+
mobile: 'metro.restarted',
|
|
118
|
+
extension: 'chrome.reopened',
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export interface HealBoundViolation {
|
|
122
|
+
code: string;
|
|
123
|
+
exitCode: number;
|
|
124
|
+
message: string;
|
|
125
|
+
userAction?: string;
|
|
126
|
+
// The ORIGINAL failure output that drove this classification, carried verbatim.
|
|
127
|
+
// The classification `message` accompanies it — it NEVER replaces it: an
|
|
128
|
+
// app-logic assertion like "expected balance 10, got 7" must survive so the
|
|
129
|
+
// human/agent sees the real cause, not just the note.
|
|
130
|
+
originalError?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Returns null (ok to proceed) or a violation descriptor when a bound is hit.
|
|
134
|
+
// Using null instead of { ok: true } keeps the return typeof-narrowable without
|
|
135
|
+
// relying on discriminated-union narrowing (avoids strict-mode tsconfig issues).
|
|
136
|
+
export function checkHealBounds(
|
|
137
|
+
target: string,
|
|
138
|
+
output: string,
|
|
139
|
+
state: HealState,
|
|
140
|
+
): HealBoundViolation | null {
|
|
141
|
+
// Carry the original failure output verbatim on every violation so the caller
|
|
142
|
+
// can surface it (--json + human) alongside the classification note.
|
|
143
|
+
const originalError = output.trim() || undefined;
|
|
144
|
+
if (recipeRunning(target)) {
|
|
145
|
+
return {
|
|
146
|
+
code: 'RECIPE_RUNNING',
|
|
147
|
+
exitCode: EXIT.bounded,
|
|
148
|
+
message: 'a recipe is currently running — refusing recovery to avoid corrupting mid-run state.',
|
|
149
|
+
originalError,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const failureClass = classifyFailure(output);
|
|
153
|
+
if (failureClass === 'wallet') {
|
|
154
|
+
return {
|
|
155
|
+
code: 'WALLET_STATE_REQUIRED',
|
|
156
|
+
exitCode: EXIT.bounded,
|
|
157
|
+
message: 'recovery would require a seeded wallet — healing never touches fixtures.',
|
|
158
|
+
userAction: 'run mm-harness fixtures set',
|
|
159
|
+
originalError,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (failureClass === 'app') {
|
|
163
|
+
return {
|
|
164
|
+
code: 'APP_LOGIC_FAILURE',
|
|
165
|
+
exitCode: EXIT.runtime,
|
|
166
|
+
message: 'failure looks like app-logic — healing cannot help; surface verbatim.',
|
|
167
|
+
originalError,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (state.attemptedRecoveries.length > 0) {
|
|
171
|
+
return {
|
|
172
|
+
code: 'SAME_RECOVERY_TWICE',
|
|
173
|
+
exitCode: EXIT.bounded,
|
|
174
|
+
message: 'same recovery already failed once this invocation — refusing to loop.',
|
|
175
|
+
originalError,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|