@deeeed/metamask-harness 0.3.7 → 0.3.9
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 +39 -0
- package/adapters/extension/start-watch.sh +8 -1
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/lib/tmux-viewer.sh +38 -0
- package/adapters/mobile/start-metro.sh +4 -18
- package/adapters/mobile/stop-metro.sh +66 -0
- package/adapters/mobile/yarn-setup.sh +14 -2
- package/adapters/shared/resolve-farmslot-ports.sh +21 -0
- package/docs/UX-PRINCIPLES.md +61 -0
- package/package.json +8 -3
- package/scripts/completions.sh +6 -3
- package/src/adapters/mobile/prepare.ts +42 -17
- package/src/adapters/mobile/runtime-decision.ts +103 -42
- package/src/cli.ts +77 -4
- package/src/commands/launch.ts +87 -11
- package/src/commands/shared.ts +62 -1
- package/src/doctor.ts +25 -13
- package/src/mm-harness-cli.ts +59 -6
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
@@ -43,6 +44,9 @@ export interface MobileRuntimeDecisionReport {
|
|
|
43
44
|
decision: MobileReadinessDecision;
|
|
44
45
|
reasonCode: string;
|
|
45
46
|
reasons: string[];
|
|
47
|
+
// A single teaching escape for the caller's actual situation, set on decisions
|
|
48
|
+
// the runner deliberately refuses to auto-resolve (e.g. fast-mode deps gaps).
|
|
49
|
+
userAction?: string;
|
|
46
50
|
checks: {
|
|
47
51
|
deps: DepsCheck;
|
|
48
52
|
metroLog: MetroLogCheck;
|
|
@@ -56,6 +60,9 @@ export interface MobileRuntimeDecisionOptions {
|
|
|
56
60
|
metroLog?: string;
|
|
57
61
|
platform?: string;
|
|
58
62
|
record?: boolean;
|
|
63
|
+
// 'fast' (quick launch / no --build) makes deps readiness a presence check only:
|
|
64
|
+
// freshness is the orchestrator's deps-phase contract, never re-decided here.
|
|
65
|
+
preflightMode?: string;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
const BUNDLE_ERR = /Bundling failed|Unable to resolve /u;
|
|
@@ -128,38 +135,38 @@ function missingRequiredDeps(target: string): string[] {
|
|
|
128
135
|
}
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
// Returns true when any manifest input (package.json or yarn.lock) is newer than
|
|
132
|
-
// the install markers written by yarn on a successful install. A reliable signal
|
|
133
|
-
// that deps changed since the last install, even when depsCheck found no recorded
|
|
134
|
-
// baseline drift (e.g. git checkout preserved an old author-date mtime on the
|
|
135
|
-
// manifest, then a new dep was committed, making the manifest newer than markers).
|
|
136
|
-
function depsStaleByMtime(target: string): boolean {
|
|
137
|
-
// Use the same install markers as depsCheck: the files yarn writes on install.
|
|
138
|
-
const INSTALL_MARKERS = ['node_modules/.yarn-state.yml', '.yarn/install-state.gz'];
|
|
139
|
-
let markerMtime = 0;
|
|
140
|
-
for (const rel of INSTALL_MARKERS) {
|
|
141
|
-
try {
|
|
142
|
-
const mt = fs.statSync(path.join(target, rel)).mtimeMs;
|
|
143
|
-
if (mt > markerMtime) markerMtime = mt;
|
|
144
|
-
} catch {
|
|
145
|
-
// marker absent; keep searching
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (markerMtime === 0) return false; // no markers → depsCheck already handles via 'missing'
|
|
149
|
-
for (const file of ['package.json', 'yarn.lock']) {
|
|
150
|
-
try {
|
|
151
|
-
if (fs.statSync(path.join(target, file)).mtimeMs > markerMtime) return true;
|
|
152
|
-
} catch {
|
|
153
|
-
// manifest absent; skip
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
138
|
const installActions = (target: string): MobileRuntimeDecisionAction[] => [
|
|
160
139
|
{ id: 'yarn-setup', argv: ['yarn', 'setup'], cwd: target },
|
|
161
140
|
];
|
|
162
141
|
|
|
142
|
+
// True when a MetaMask dev client process is alive on the target device
|
|
143
|
+
// (IOS_SIMULATOR / ADB_SERIAL, falling back to the booted simulator). Probe
|
|
144
|
+
// failures count as not-running: the worst case is an idempotent relaunch,
|
|
145
|
+
// while failing open would declare a dead app "ready".
|
|
146
|
+
function appRunningOnDevice(platform?: string): boolean {
|
|
147
|
+
try {
|
|
148
|
+
if (platform === 'android') {
|
|
149
|
+
const serial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL;
|
|
150
|
+
const args = serial ? ['-s', serial] : [];
|
|
151
|
+
const out = execFileSync('adb', [...args, 'shell', 'ps', '-A'], {
|
|
152
|
+
encoding: 'utf8',
|
|
153
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
154
|
+
timeout: 5_000,
|
|
155
|
+
});
|
|
156
|
+
return out.includes('io.metamask');
|
|
157
|
+
}
|
|
158
|
+
const device = process.env.IOS_SIMULATOR || 'booted';
|
|
159
|
+
const out = execFileSync('xcrun', ['simctl', 'spawn', device, 'launchctl', 'list'], {
|
|
160
|
+
encoding: 'utf8',
|
|
161
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
162
|
+
timeout: 5_000,
|
|
163
|
+
});
|
|
164
|
+
return out.toLowerCase().includes('io.metamask');
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
const launchActions = (target: string, clearMetro = false): MobileRuntimeDecisionAction[] => {
|
|
164
171
|
const actions: MobileRuntimeDecisionAction[] = [];
|
|
165
172
|
// start-metro ensures Metro is running; --clear resets the bundle cache.
|
|
@@ -184,22 +191,60 @@ export async function decideMobileReadiness(
|
|
|
184
191
|
const resolved = path.resolve(target);
|
|
185
192
|
if (options.record) recordDepsBaseline(resolved);
|
|
186
193
|
|
|
194
|
+
const fast = options.preflightMode === 'fast';
|
|
195
|
+
const report = await computeMobileReadiness(resolved, options, fast);
|
|
196
|
+
|
|
197
|
+
// mtime churn is already neutralized above, so a fast-mode 'install' verdict
|
|
198
|
+
// means deps genuinely need work (absent markers, an absent required package, a
|
|
199
|
+
// fingerprint-baseline drift, or Metro unable to resolve a module). In an
|
|
200
|
+
// orchestrated run the deps phase owns installation; having the runner install
|
|
201
|
+
// mid-launch is inverted authority. Surface a teaching block, not an implicit
|
|
202
|
+
// setup, so a runway preflight never re-does the orchestrator's deps work.
|
|
203
|
+
if (fast && report.decision === 'install') {
|
|
204
|
+
return {
|
|
205
|
+
...report,
|
|
206
|
+
decision: 'blocked',
|
|
207
|
+
reasonCode: 'deps-not-ready',
|
|
208
|
+
reasons: [
|
|
209
|
+
'Fast preflight found dependencies not ready and does not install them (the orchestrator deps phase owns installation).',
|
|
210
|
+
...report.reasons,
|
|
211
|
+
],
|
|
212
|
+
userAction:
|
|
213
|
+
'run the slot deps/prepare phase; standalone: `yarn setup:expo --no-build-ios --no-build-android` in the checkout, or `mm-harness launch <platform> --build` to install and build',
|
|
214
|
+
actions: [],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return report;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function computeMobileReadiness(
|
|
221
|
+
resolved: string,
|
|
222
|
+
options: MobileRuntimeDecisionOptions,
|
|
223
|
+
fast: boolean,
|
|
224
|
+
): Promise<MobileRuntimeDecisionReport> {
|
|
187
225
|
const rawDeps = depsCheck(resolved, {
|
|
188
226
|
productMarkers: mobileProductMarkers(options.platform),
|
|
189
227
|
});
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
228
|
+
// Presence is authoritative; mtime is only a hint. Without a recorded baseline
|
|
229
|
+
// the only way depsCheck reports 'stale' is mtime drift (a manifest newer than
|
|
230
|
+
// the install markers). In a managed checkout the orchestrator's git phase
|
|
231
|
+
// refreshes tracked-file mtimes on every sync while its deps phase leaves
|
|
232
|
+
// node_modules untouched, so "manifest newer than markers" is normal and NOT
|
|
233
|
+
// proof of drift — treating it as stale re-ran the full yarn setup inside every
|
|
234
|
+
// launch. Trust the installed node_modules and warn; genuine drift is caught by
|
|
235
|
+
// the recorded-baseline fingerprint when one exists (hasBaseline=true stays 'stale').
|
|
236
|
+
let deps: DepsCheck = rawDeps;
|
|
237
|
+
if (rawDeps.status === 'stale' && !rawDeps.hasBaseline) {
|
|
238
|
+
deps = { installed: rawDeps.installed, status: 'current', hasBaseline: false };
|
|
239
|
+
process.stderr.write(
|
|
240
|
+
'[runtime-decision] manifest is newer than install markers (no recorded baseline); ' +
|
|
241
|
+
'trusting installed node_modules — run yarn setup manually if deps truly changed.\n',
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
// Required-dep absence is a presence check (both modes): a top-level dependency
|
|
245
|
+
// can be absent from node_modules even when markers + fingerprint report
|
|
246
|
+
// 'current' (e.g. added in a branch merge but install not re-run). Catch at
|
|
247
|
+
// pre-flight before Metro discovers it during a long bundle run.
|
|
203
248
|
if (deps.status === 'current') {
|
|
204
249
|
const absent = missingRequiredDeps(resolved);
|
|
205
250
|
if (absent.length > 0) {
|
|
@@ -373,13 +418,29 @@ export async function decideMobileReadiness(
|
|
|
373
418
|
}
|
|
374
419
|
|
|
375
420
|
if (metro.status === 'up' && metroLog.status === 'ok') {
|
|
421
|
+
// Metro health alone is not runtime readiness: the dev client may be
|
|
422
|
+
// installed but not running on the target device (launch must launch).
|
|
423
|
+
// Probe the device before declaring ready; a healthy Metro with a dead
|
|
424
|
+
// app relaunches the client without touching Metro.
|
|
425
|
+
if (!appRunningOnDevice(options.platform)) {
|
|
426
|
+
return {
|
|
427
|
+
schemaVersion: 1,
|
|
428
|
+
adapter: 'mobile',
|
|
429
|
+
target: resolved,
|
|
430
|
+
decision: 'launch',
|
|
431
|
+
reasonCode: 'app-not-running',
|
|
432
|
+
reasons: ['Metro is healthy but the dev client is not running on the target device; launching it.'],
|
|
433
|
+
checks,
|
|
434
|
+
actions: launchActions(resolved),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
376
437
|
return {
|
|
377
438
|
schemaVersion: 1,
|
|
378
439
|
adapter: 'mobile',
|
|
379
440
|
target: resolved,
|
|
380
441
|
decision: 'ready',
|
|
381
442
|
reasonCode: 'healthy',
|
|
382
|
-
reasons: ['Dependencies are current
|
|
443
|
+
reasons: ['Dependencies are current, Metro reports a successful bundle, and the dev client is running. Verify bridge before replay.'],
|
|
383
444
|
checks,
|
|
384
445
|
actions: [],
|
|
385
446
|
};
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
import { color } from './cli-color.ts';
|
|
7
8
|
import { createDoctorReport, renderRuntimeContext } from './doctor.ts';
|
|
8
9
|
import {
|
|
9
10
|
invalidateCompletionCache,
|
|
@@ -420,7 +421,7 @@ async function runSelfTest(options: CliOptions) {
|
|
|
420
421
|
}
|
|
421
422
|
|
|
422
423
|
async function handleManifest({ options }: ParsedArgs): Promise<number> {
|
|
423
|
-
const adapter =
|
|
424
|
+
const { adapter } = resolveAdapter(options);
|
|
424
425
|
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
425
426
|
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
426
427
|
await validateManifest(manifest);
|
|
@@ -430,7 +431,7 @@ async function handleManifest({ options }: ParsedArgs): Promise<number> {
|
|
|
430
431
|
}
|
|
431
432
|
|
|
432
433
|
async function handleActions({ options }: ParsedArgs): Promise<number> {
|
|
433
|
-
const adapter =
|
|
434
|
+
const { adapter } = resolveAdapter(options);
|
|
434
435
|
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
435
436
|
await validateManifest(manifest);
|
|
436
437
|
const action = optionString(options, 'action');
|
|
@@ -475,9 +476,38 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
|
|
|
475
476
|
}
|
|
476
477
|
|
|
477
478
|
const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
|
|
478
|
-
|
|
479
|
+
// Live runtime state, per platform: mobile reports the same read-only readiness
|
|
480
|
+
// the launch decision uses (deps currency, Metro on the slot's port, decision +
|
|
481
|
+
// reasons). Extension/core gain their sections when their readiness probes exist.
|
|
482
|
+
let runtime: { decision: string; reasonCode?: string; reasons: string[]; deps?: string; metro?: string } | undefined;
|
|
483
|
+
if (adapter === 'mobile') {
|
|
484
|
+
try {
|
|
485
|
+
const { mobileRuntimeStatus } = await import('./adapters/mobile/prepare.ts');
|
|
486
|
+
const { resolveMobileSlotPorts } = await import('./commands/launch.ts');
|
|
487
|
+
resolveMobileSlotPorts(target);
|
|
488
|
+
const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
|
|
489
|
+
const report = await mobileRuntimeStatus(target, { watcherPort });
|
|
490
|
+
runtime = {
|
|
491
|
+
decision: report.decision,
|
|
492
|
+
reasonCode: report.reasonCode,
|
|
493
|
+
reasons: report.reasons,
|
|
494
|
+
deps: report.checks?.deps?.status,
|
|
495
|
+
metro: report.checks?.metro?.status,
|
|
496
|
+
};
|
|
497
|
+
} catch { /* readiness probe unavailable — doctor stays useful without it */ }
|
|
498
|
+
}
|
|
499
|
+
if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
|
|
479
500
|
else {
|
|
480
|
-
|
|
501
|
+
const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
|
|
502
|
+
const stateStyle = (value: string | undefined, good: string) => (value === good ? 'ok' : 'warn');
|
|
503
|
+
console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
|
|
504
|
+
if (runtime) {
|
|
505
|
+
const decisionStyle = runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
|
|
506
|
+
console.log(
|
|
507
|
+
`${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(stateStyle(runtime.deps, 'current'), runtime.deps ?? 'unknown')} metro=${out(stateStyle(runtime.metro, 'up'), runtime.metro ?? 'unprobed')}`,
|
|
508
|
+
);
|
|
509
|
+
for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
|
|
510
|
+
}
|
|
481
511
|
console.log(renderRuntimeContext(result.runtimeContext));
|
|
482
512
|
}
|
|
483
513
|
return result.status === 'pass' ? 0 : 1;
|
|
@@ -758,6 +788,7 @@ async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
|
|
|
758
788
|
metroLog: optionString(options, 'metroLog'),
|
|
759
789
|
platform: optionString(options, 'platform') ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
|
|
760
790
|
record: optionFlag(options, 'record'),
|
|
791
|
+
preflightMode: optionString(options, 'preflightMode'),
|
|
761
792
|
});
|
|
762
793
|
if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
|
|
763
794
|
else console.log(`${report.decision} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
|
|
@@ -1082,6 +1113,47 @@ function emitPlanUsageError(
|
|
|
1082
1113
|
// hand it to runRecipe). One execution path, two doors: `call` = one node, `run` =
|
|
1083
1114
|
// a graph. Inherits run semantics: always-validates (adapter-aware, exit 5), same
|
|
1084
1115
|
// trace/evidence artifacts, same --json contract.
|
|
1116
|
+
async function handleStop(argv: string[]): Promise<number> {
|
|
1117
|
+
const { options } = parseArgs(argv, 'stop');
|
|
1118
|
+
const json = optionFlag(options, 'json');
|
|
1119
|
+
const { adapter, target } = resolveAdapter(options);
|
|
1120
|
+
if (adapter !== 'mobile') {
|
|
1121
|
+
return usageOut(
|
|
1122
|
+
json,
|
|
1123
|
+
'stop',
|
|
1124
|
+
`stop owns the mobile Metro dev server; the ${adapter} dev server is slot-managed`,
|
|
1125
|
+
'stop the extension watcher via its runtime pids: kill $(cat <runtime_dir>/webpack.pid); core runs no dev server',
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
const leaf = path.join(runnerDir, 'adapters', 'mobile', 'stop-metro.sh');
|
|
1129
|
+
const args = ['--target', target];
|
|
1130
|
+
const port = optionString(options, 'port') ?? optionString(options, 'watcherPort');
|
|
1131
|
+
if (port) args.push('--port', port);
|
|
1132
|
+
const result = spawnSync('bash', [leaf, ...args], {
|
|
1133
|
+
stdio: json ? 'pipe' : 'inherit',
|
|
1134
|
+
encoding: 'utf8',
|
|
1135
|
+
});
|
|
1136
|
+
const exitCode = result.status ?? 1;
|
|
1137
|
+
if (json) {
|
|
1138
|
+
console.log(
|
|
1139
|
+
JSON.stringify(
|
|
1140
|
+
{
|
|
1141
|
+
schemaVersion: 1,
|
|
1142
|
+
command: 'stop',
|
|
1143
|
+
adapter,
|
|
1144
|
+
target,
|
|
1145
|
+
status: exitCode === 0 ? 'pass' : 'fail',
|
|
1146
|
+
exitCode,
|
|
1147
|
+
output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(),
|
|
1148
|
+
},
|
|
1149
|
+
null,
|
|
1150
|
+
2,
|
|
1151
|
+
),
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
return exitCode;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1085
1157
|
async function handleCall(argv: string[]): Promise<number> {
|
|
1086
1158
|
// Grammar: mm-harness call <action> [--arg k=v ...] [flags].
|
|
1087
1159
|
// If the first token is a flag, the action positional is missing — parseCallArgs
|
|
@@ -1803,6 +1875,7 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
1803
1875
|
// launch/logs/debug/fixtures compose adapter scripts directly and own policy,
|
|
1804
1876
|
// healing, teaching, and the --json contract.
|
|
1805
1877
|
if (command === 'launch') return handleLaunch(argv.slice(1));
|
|
1878
|
+
if (command === 'stop') return handleStop(argv.slice(1));
|
|
1806
1879
|
if (command === 'logs') return handleLogs(argv.slice(1));
|
|
1807
1880
|
if (command === 'debug') return handleDebug(argv.slice(1));
|
|
1808
1881
|
if (command === 'fixtures') return handleFixtures(argv.slice(1), { runOneNode });
|
package/src/commands/launch.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { execFileSync } from 'node:child_process';
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { color } from '../cli-color.ts';
|
|
12
|
+
import { handleHarness, readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
|
|
12
13
|
import { recipeHarnessPath, recipeRuntimeDir, recipeRuntimePath, runnerDir } from '../paths.ts';
|
|
13
14
|
import type { MetaMaskRecipeAdapter } from '../types.ts';
|
|
14
15
|
import { prepareMobile } from '../adapters/mobile/prepare.ts';
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
parseFlags,
|
|
20
21
|
resolveAdapter,
|
|
21
22
|
spawnScript,
|
|
23
|
+
spawnScriptStreaming,
|
|
22
24
|
str,
|
|
23
25
|
targetOf,
|
|
24
26
|
usageOut,
|
|
@@ -227,15 +229,33 @@ function applyKVLines(output: string, overwrite: boolean): void {
|
|
|
227
229
|
case 'SLOT_ID':
|
|
228
230
|
if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
|
|
229
231
|
break;
|
|
232
|
+
case 'CDP_PORT':
|
|
233
|
+
if (overwrite || !process.env['CDP_PORT']) {
|
|
234
|
+
process.env['CDP_PORT'] = val;
|
|
235
|
+
process.env['RECIPE_CDP_PORT'] = val;
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
230
238
|
}
|
|
231
239
|
}
|
|
232
240
|
}
|
|
233
241
|
|
|
234
|
-
// Resolve mobile slot port/simulator
|
|
235
|
-
//
|
|
242
|
+
// Resolve mobile slot port/simulator: the slot context the orchestrator wrote
|
|
243
|
+
// into the checkout wins first, then the farmslot pool (both overwrite env),
|
|
244
|
+
// then the slot-suffix formula (only fills unset vars). Called before explicit
|
|
236
245
|
// CLI flag overrides so flags always win at the top.
|
|
237
|
-
function resolveMobileSlotPorts(target: string): void {
|
|
246
|
+
export function resolveMobileSlotPorts(target: string): void {
|
|
238
247
|
const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
|
|
248
|
+
try {
|
|
249
|
+
// The checkout's own runtime context is authoritative — it names the exact
|
|
250
|
+
// simulator/port this slot was prepared with, surviving pool renames.
|
|
251
|
+
const ctxOut = execFileSync('bash', [
|
|
252
|
+
'-c', `source "${resolveScript}" && resolve_mobile_runtime_context "${target}"`,
|
|
253
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
254
|
+
if (ctxOut.trim()) {
|
|
255
|
+
applyKVLines(ctxOut, true);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
} catch { /* no runtime context — fall through to pool */ }
|
|
239
259
|
try {
|
|
240
260
|
// Pool match always wins — overwrite whatever is in the environment.
|
|
241
261
|
const poolOut = execFileSync('bash', [
|
|
@@ -255,6 +275,43 @@ function resolveMobileSlotPorts(target: string): void {
|
|
|
255
275
|
} catch { /* no slot suffix in dir name — stays at env defaults */ }
|
|
256
276
|
}
|
|
257
277
|
|
|
278
|
+
// Resolve extension slot ports the same way as mobile: the checkout's runtime
|
|
279
|
+
// context first (cdpPort/devServerPort written by the orchestrator's prepare),
|
|
280
|
+
// then the farmslot pool, then the directory-suffix formula (fills unset only).
|
|
281
|
+
function resolveExtensionSlotPorts(target: string): void {
|
|
282
|
+
// The prepared checkout's context OVERWRITES inherited env (same authority as
|
|
283
|
+
// mobile's context/pool resolution): a stale CDP_PORT from the shell must not
|
|
284
|
+
// hijack the slot's browser. Explicit CLI flags are applied after and win.
|
|
285
|
+
const contextPath = resolveRuntimeContextPath(target);
|
|
286
|
+
const cdp = readRuntimeContextField(contextPath, 'cdpPort');
|
|
287
|
+
if (cdp) {
|
|
288
|
+
process.env['CDP_PORT'] = cdp;
|
|
289
|
+
process.env['RECIPE_CDP_PORT'] = cdp;
|
|
290
|
+
}
|
|
291
|
+
const dev = readRuntimeContextField(contextPath, 'devServerPort');
|
|
292
|
+
if (dev) {
|
|
293
|
+
process.env['WATCHER_PORT'] = dev;
|
|
294
|
+
process.env['RECIPE_WATCHER_PORT'] = dev;
|
|
295
|
+
}
|
|
296
|
+
if (process.env['CDP_PORT']) return;
|
|
297
|
+
const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
|
|
298
|
+
try {
|
|
299
|
+
const poolOut = execFileSync('bash', [
|
|
300
|
+
'-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
|
|
301
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
302
|
+
if (poolOut.trim()) {
|
|
303
|
+
applyKVLines(poolOut, true);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
} catch { /* no pool match — fall through to formula */ }
|
|
307
|
+
try {
|
|
308
|
+
const defOut = execFileSync('bash', [
|
|
309
|
+
'-c', `source "${resolveScript}" && resolve_default_extension_ports "${target}"`,
|
|
310
|
+
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
311
|
+
if (defOut.trim()) applyKVLines(defOut, false);
|
|
312
|
+
} catch { /* no slot suffix in dir name — stays at env defaults */ }
|
|
313
|
+
}
|
|
314
|
+
|
|
258
315
|
// Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
|
|
259
316
|
// port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
|
|
260
317
|
// so that the harness respects slot isolation; explicit CLI flags applied below
|
|
@@ -265,9 +322,12 @@ function applyLaunchEnvOverrides(
|
|
|
265
322
|
mobileTarget: string | undefined,
|
|
266
323
|
target: string,
|
|
267
324
|
): void {
|
|
268
|
-
//
|
|
269
|
-
// the farmslot pool or directory-suffix formula BEFORE applying explicit
|
|
325
|
+
// Slot isolation: resolve ports/device from the checkout's own slot context,
|
|
326
|
+
// the farmslot pool, or the directory-suffix formula BEFORE applying explicit
|
|
327
|
+
// flags — extension needs this as much as mobile (CDP_PORT), so neither
|
|
328
|
+
// adapter hard-fails on a value its slot already knows.
|
|
270
329
|
if (adapter === 'mobile') resolveMobileSlotPorts(target);
|
|
330
|
+
if (adapter === 'extension') resolveExtensionSlotPorts(target);
|
|
271
331
|
|
|
272
332
|
const device = str(options, 'device');
|
|
273
333
|
if (device && adapter === 'mobile') {
|
|
@@ -346,17 +406,19 @@ async function executeComposition(
|
|
|
346
406
|
return extensionRebuild(target, json);
|
|
347
407
|
}
|
|
348
408
|
|
|
349
|
-
// Extension build or watch: full webpack compile via start-watch.sh.
|
|
409
|
+
// Extension build or watch: full webpack compile via start-watch.sh. Streams
|
|
410
|
+
// live — a webpack compile is minutes long and must never look hung.
|
|
350
411
|
const startWatchSh = path.join(runnerDir, 'adapters/extension/start-watch.sh');
|
|
351
412
|
const watchArgs = ['--target', target];
|
|
352
413
|
if (process.env.WATCHER_PORT) watchArgs.push('--watcher-port', process.env.WATCHER_PORT);
|
|
353
|
-
|
|
414
|
+
console.error(`→ extension ${wantWatch ? 'watch' : 'build'} — webpack :${process.env.WATCHER_PORT ?? 'default'} (output streams below)`);
|
|
415
|
+
return spawnScriptStreaming(startWatchSh, watchArgs, target);
|
|
354
416
|
}
|
|
355
417
|
|
|
356
418
|
// Extension rebuild: kill the harness-owned webpack watcher (pid file + ps-scan
|
|
357
419
|
// for orphans), clear the rebuild log, then drive the installed overlay's
|
|
358
420
|
// live.sh --start-watch and tee its output into the rebuild log.
|
|
359
|
-
function extensionRebuild(target: string, json: boolean): ScriptResult {
|
|
421
|
+
async function extensionRebuild(target: string, json: boolean): Promise<ScriptResult> {
|
|
360
422
|
const runtimeDirRel = recipeRuntimeDir();
|
|
361
423
|
const runtimeAbs = path.join(target, runtimeDirRel);
|
|
362
424
|
const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
|
|
@@ -418,7 +480,10 @@ function extensionRebuild(target: string, json: boolean): ScriptResult {
|
|
|
418
480
|
const liveArgs = ['--target', target, '--start-watch'];
|
|
419
481
|
if (process.env.CDP_PORT) liveArgs.push('--cdp-port', process.env.CDP_PORT);
|
|
420
482
|
if (process.env.WATCHER_PORT) liveArgs.push('--watcher-port', process.env.WATCHER_PORT);
|
|
421
|
-
|
|
483
|
+
// Streams live: the watcher restart + Chromium boot runs for minutes and the
|
|
484
|
+
// command must show progress immediately, not a silent prompt.
|
|
485
|
+
console.error(`→ extension quick relaunch — webpack :${process.env.WATCHER_PORT ?? 'default'} · CDP :${process.env.CDP_PORT ?? 'default'} (output streams below)`);
|
|
486
|
+
const result = await spawnScriptStreaming(liveScript, liveArgs, target);
|
|
422
487
|
// Tee live.sh output into rebuild.log for post-mortem diagnosis.
|
|
423
488
|
if (result.output) {
|
|
424
489
|
try { fs.appendFileSync(rebuildLog, result.output); } catch { /* best-effort */ }
|
|
@@ -489,7 +554,18 @@ function launchPass(
|
|
|
489
554
|
),
|
|
490
555
|
);
|
|
491
556
|
} else {
|
|
492
|
-
|
|
557
|
+
// The summary must say what actually happened where: the target device,
|
|
558
|
+
// what the tier meant, and that the app+bridge are up — "(quick) ready"
|
|
559
|
+
// alone reads as a no-op even when a launch occurred.
|
|
560
|
+
const device =
|
|
561
|
+
adapter === 'mobile'
|
|
562
|
+
? process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || 'booted device'
|
|
563
|
+
: displayMode;
|
|
564
|
+
const tierNote = tier === 'quick' ? 'quick relaunch, no native build' : tier;
|
|
565
|
+
const devNote = process.env.MM_HARNESS_BIN ? ` ${color('dim', '[dev: MM_HARNESS_BIN]')}` : '';
|
|
566
|
+
console.error(
|
|
567
|
+
`${color('ok', '✓')} launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ''} — ${color('ok', String(device))} · ${tierNote} · app + bridge ready${devNote}`,
|
|
568
|
+
);
|
|
493
569
|
}
|
|
494
570
|
return EXIT.ok;
|
|
495
571
|
}
|
package/src/commands/shared.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// For node invocations (bin === process.execPath) the stem is derived from the
|
|
8
8
|
// script path in args[0], e.g. MM_HARNESS_SCRIPT_BIN_OPEN_DEBUG_MJS.
|
|
9
9
|
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
|
|
13
13
|
import { detectAdapter } from '../harness.ts';
|
|
@@ -151,6 +151,67 @@ export function spawnScript(
|
|
|
151
151
|
return { status: result.status ?? 1, output };
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// Streaming variant of spawnScript for long-running leaves (mobile prepare:
|
|
155
|
+
// native build + Metro + health-bridge poll, minutes long). spawnScript buffers
|
|
156
|
+
// via spawnSync and, in --json mode, suppresses output entirely — so those leaves
|
|
157
|
+
// run with zero feedback until exit. This tees the child's stdout+stderr to the
|
|
158
|
+
// parent's STDERR live (so the --json envelope on stdout stays clean) while still
|
|
159
|
+
// capturing the combined output for heal classification. Same seam, leaf-invoke
|
|
160
|
+
// resolution, missing-leaf pre-check, and spawn-error contract as spawnScript.
|
|
161
|
+
export function spawnScriptStreaming(
|
|
162
|
+
script: string,
|
|
163
|
+
args: string[],
|
|
164
|
+
cwd: string,
|
|
165
|
+
env?: Record<string, string>,
|
|
166
|
+
): Promise<ScriptResult> {
|
|
167
|
+
const isNodeScript = script === process.execPath && args.length > 0;
|
|
168
|
+
const stem = isNodeScript
|
|
169
|
+
? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase()
|
|
170
|
+
: path.basename(script).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase();
|
|
171
|
+
const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
|
|
172
|
+
const bin = override ?? script;
|
|
173
|
+
const directArgs = override !== undefined && isNodeScript ? args.slice(1) : args;
|
|
174
|
+
|
|
175
|
+
if (shellLeafMissing(bin)) {
|
|
176
|
+
const message =
|
|
177
|
+
`leaf could not start: ${path.basename(bin)} (ENOENT)\n` +
|
|
178
|
+
` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
|
|
179
|
+
process.stderr.write(`${message}\n`);
|
|
180
|
+
return Promise.resolve({ status: 1, output: message });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
|
|
184
|
+
return new Promise<ScriptResult>((resolve) => {
|
|
185
|
+
const child = spawn(invokeBin, spawnArgs, {
|
|
186
|
+
cwd,
|
|
187
|
+
env: env ? { ...process.env, ...env } : process.env,
|
|
188
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
189
|
+
});
|
|
190
|
+
let output = '';
|
|
191
|
+
// Route BOTH child streams to the parent's stderr, live: stdout is reserved
|
|
192
|
+
// for the harness --json envelope, so all human/leaf progress goes to stderr.
|
|
193
|
+
const tee = (chunk: Buffer): void => {
|
|
194
|
+
const text = chunk.toString('utf8');
|
|
195
|
+
output += text;
|
|
196
|
+
process.stderr.write(text);
|
|
197
|
+
};
|
|
198
|
+
child.stdout?.on('data', tee);
|
|
199
|
+
child.stderr?.on('data', tee);
|
|
200
|
+
child.on('error', (error: NodeJS.ErrnoException) => {
|
|
201
|
+
const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
|
|
202
|
+
const code = error.code ?? 'ESPAWN';
|
|
203
|
+
const message =
|
|
204
|
+
`leaf could not start: ${leaf} (${code})\n` +
|
|
205
|
+
` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
|
|
206
|
+
process.stderr.write(`${message}\n`);
|
|
207
|
+
resolve({ status: 1, output: message });
|
|
208
|
+
});
|
|
209
|
+
child.on('close', (status) => {
|
|
210
|
+
resolve({ status: status ?? 1, output });
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
154
215
|
// Both escapes from a failed repo-type detection (defined at the detection source
|
|
155
216
|
// in harness.ts): where to run it (checkout/target) and how to force it (adapter).
|
|
156
217
|
export { ADAPTER_DETECT_NEXT } from '../harness.ts';
|
package/src/doctor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
+
import { color } from './cli-color.ts';
|
|
4
5
|
import { readRuntimeContextField, resolveRuntimeContextPath } from './harness.ts';
|
|
5
6
|
import { manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from './paths.ts';
|
|
6
7
|
import type {
|
|
@@ -100,26 +101,32 @@ const RUNTIME_CONTEXT_FIELDS: ReadonlyArray<{
|
|
|
100
101
|
envVars: readonly string[];
|
|
101
102
|
envVar: string | null;
|
|
102
103
|
customize: string;
|
|
104
|
+
adapters: readonly MetaMaskRecipeAdapter[];
|
|
103
105
|
}> = [
|
|
104
|
-
{ key: 'slotId', envVars: ['RECIPE_SLOT_ID'], envVar: 'RECIPE_SLOT_ID', customize: 'farmslot dispatch writes this' },
|
|
105
|
-
{ key: 'extensionId', envVars: ['RECIPE_HARNESS_EXTENSION_ID'], envVar: 'RECIPE_HARNESS_EXTENSION_ID', customize: 'auto-resolved; edit file to pin' },
|
|
106
|
-
{ key: 'cdpPort', envVars: ['RECIPE_CDP_PORT', 'CDP_PORT'], envVar: 'CDP_PORT', customize: 'edit file or pass --cdp-port' },
|
|
107
|
-
{ key: 'runtimeStart.approved', envVars: ['RECIPE_RUNTIME_START_APPROVED'], envVar: 'RECIPE_RUNTIME_START_APPROVED', customize: 'edit file (true/false)' },
|
|
108
|
-
{ key: 'runtimeStart.command', envVars: [], envVar: null, customize: 'edit file' },
|
|
109
|
-
{ key: 'runtimeStart.readyUrl', envVars: ['RECIPE_RUNTIME_READY_URL'], envVar: 'RECIPE_RUNTIME_READY_URL', customize: 'edit file' },
|
|
106
|
+
{ key: 'slotId', envVars: ['RECIPE_SLOT_ID'], envVar: 'RECIPE_SLOT_ID', customize: 'farmslot dispatch writes this', adapters: ['mobile', 'extension', 'core'] },
|
|
107
|
+
{ key: 'extensionId', envVars: ['RECIPE_HARNESS_EXTENSION_ID'], envVar: 'RECIPE_HARNESS_EXTENSION_ID', customize: 'auto-resolved; edit file to pin', adapters: ['extension'] },
|
|
108
|
+
{ key: 'cdpPort', envVars: ['RECIPE_CDP_PORT', 'CDP_PORT'], envVar: 'CDP_PORT', customize: 'edit file or pass --cdp-port', adapters: ['extension'] },
|
|
109
|
+
{ key: 'runtimeStart.approved', envVars: ['RECIPE_RUNTIME_START_APPROVED'], envVar: 'RECIPE_RUNTIME_START_APPROVED', customize: 'edit file (true/false)', adapters: ['mobile', 'extension'] },
|
|
110
|
+
{ key: 'runtimeStart.command', envVars: [], envVar: null, customize: 'edit file', adapters: ['mobile', 'extension'] },
|
|
111
|
+
{ key: 'runtimeStart.readyUrl', envVars: ['RECIPE_RUNTIME_READY_URL'], envVar: 'RECIPE_RUNTIME_READY_URL', customize: 'edit file', adapters: ['mobile', 'extension'] },
|
|
110
112
|
];
|
|
111
113
|
|
|
112
114
|
// Report every runtime-context field with its current value and where it came from
|
|
113
115
|
// (live env override > file > unset default), reusing the harness's own file reader
|
|
114
116
|
// so doctor and dispatch agree on resolution. Absent file → fileExists:false and
|
|
115
117
|
// every field falls back to env or default; the path shows where it WOULD live.
|
|
116
|
-
|
|
118
|
+
// An adapter scopes the report to that platform's fields — an extension-only row
|
|
119
|
+
// (cdpPort, extensionId) is noise on a mobile or core slot.
|
|
120
|
+
export function runtimeContextSummary(target: string, adapter?: MetaMaskRecipeAdapter): MetaMaskRuntimeContextReport {
|
|
117
121
|
const contextPath = resolveRuntimeContextPath(target);
|
|
118
122
|
const envOverride = process.env.RECIPE_RUNTIME_CONTEXT ?? null;
|
|
119
123
|
const fileExists = fs.existsSync(contextPath);
|
|
120
124
|
const file = envOverride ?? path.relative(target, contextPath);
|
|
121
125
|
const fields: Record<string, MetaMaskRuntimeContextField> = {};
|
|
122
|
-
|
|
126
|
+
const specs = adapter
|
|
127
|
+
? RUNTIME_CONTEXT_FIELDS.filter((spec) => spec.adapters.includes(adapter))
|
|
128
|
+
: RUNTIME_CONTEXT_FIELDS;
|
|
129
|
+
for (const spec of specs) {
|
|
123
130
|
const envValue = spec.envVars
|
|
124
131
|
.map((name) => process.env[name])
|
|
125
132
|
.find((value) => value !== undefined && value !== '');
|
|
@@ -137,16 +144,21 @@ export function runtimeContextSummary(target: string): MetaMaskRuntimeContextRep
|
|
|
137
144
|
|
|
138
145
|
// Human-readable runtime-context section for `doctor` without --json.
|
|
139
146
|
export function renderRuntimeContext(runtimeContext: MetaMaskRuntimeContextReport): string {
|
|
147
|
+
// Human render: set values read bright with their provenance highlighted;
|
|
148
|
+
// unset defaults read dim so the eye lands on what is actually configured.
|
|
149
|
+
const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
|
|
140
150
|
const lines: string[] = [];
|
|
141
151
|
lines.push(
|
|
142
152
|
runtimeContext.fileExists
|
|
143
|
-
?
|
|
144
|
-
:
|
|
153
|
+
? `${out('label', 'runtime-context:')} ${runtimeContext.file} ${out('ok', '(present)')}`
|
|
154
|
+
: `${out('label', 'runtime-context:')} ${runtimeContext.file} ${out('dim', '(absent — written by farmslot prepare/dispatch)')}`,
|
|
145
155
|
);
|
|
146
156
|
for (const [key, field] of Object.entries(runtimeContext.fields)) {
|
|
147
|
-
const
|
|
157
|
+
const isSet = field.value !== undefined && field.value !== null && field.value !== '';
|
|
158
|
+
const value = isSet ? out('ok', String(field.value)) : out('dim', '(unset)');
|
|
148
159
|
const origin = field.source === 'env' && field.envVar ? `env ${field.envVar}` : field.source;
|
|
149
|
-
|
|
160
|
+
const originTag = isSet ? out('accent', `[${origin}]`) : out('dim', `[${origin}]`);
|
|
161
|
+
lines.push(` ${key.padEnd(22)} ${value} ${originTag} ${out('dim', `— ${field.customize}`)}`);
|
|
150
162
|
}
|
|
151
163
|
return lines.join('\n');
|
|
152
164
|
}
|
|
@@ -189,7 +201,7 @@ export function createDoctorReport(
|
|
|
189
201
|
compatibilityMode: mode,
|
|
190
202
|
shape: repoShape(target),
|
|
191
203
|
fixture: fixtureSummary(target),
|
|
192
|
-
runtimeContext: runtimeContextSummary(target),
|
|
204
|
+
runtimeContext: runtimeContextSummary(target, adapter),
|
|
193
205
|
manifestValidation: manifestValidation.summary,
|
|
194
206
|
};
|
|
195
207
|
}
|