@deeeed/metamask-harness 0.3.8 → 0.4.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 +60 -11
- 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/ADAPTER-SURFACE.md +119 -0
- package/docs/UX-PRINCIPLES.md +64 -0
- package/package.json +13 -4
- package/scripts/completions.sh +6 -3
- package/src/adapters/core/surface.ts +56 -0
- package/src/adapters/extension/surface.ts +71 -0
- package/src/adapters/mobile/prepare.ts +22 -3
- package/src/adapters/mobile/runtime-decision.ts +103 -42
- package/src/adapters/mobile/surface.ts +59 -0
- package/src/adapters/slot-ports.ts +165 -0
- package/src/adapters/surface.ts +82 -0
- package/src/cli.ts +128 -8
- package/src/commands/debug.ts +3 -1
- package/src/commands/fixtures.ts +13 -8
- package/src/commands/launch.ts +32 -105
- package/src/commands/logs.ts +29 -13
- package/src/doctor.ts +25 -13
- package/src/mm-harness-cli.ts +72 -13
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Extension surface: delegates to the existing extension readiness/port plumbing
|
|
2
|
+
// and the webpack watcher stop.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
import { recipeRuntimePath } from '../../paths.ts';
|
|
6
|
+
import { resolveExtensionSlotPorts, stopExtensionWatcher } from '../slot-ports.ts';
|
|
7
|
+
import { decideExtensionReadiness } from './runtime-decision.ts';
|
|
8
|
+
import type {
|
|
9
|
+
AdapterDevServerStop,
|
|
10
|
+
AdapterLogSource,
|
|
11
|
+
AdapterRuntimeStatus,
|
|
12
|
+
AdapterSurface,
|
|
13
|
+
} from '../surface.ts';
|
|
14
|
+
|
|
15
|
+
// Map the webpack watch-log health onto a dev-server up/down/building/errors
|
|
16
|
+
// status doctor renders the same way as mobile's Metro line.
|
|
17
|
+
function watcherStatus(buildLog: string): string {
|
|
18
|
+
if (buildLog === 'ok') return 'up';
|
|
19
|
+
if (buildLog === 'no-watch') return 'down';
|
|
20
|
+
return buildLog; // 'building' | 'errors'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const extensionSurface: AdapterSurface = {
|
|
24
|
+
adapter: 'extension',
|
|
25
|
+
headless: false,
|
|
26
|
+
|
|
27
|
+
resolveSlotPorts(target: string): void {
|
|
28
|
+
resolveExtensionSlotPorts(target);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
|
|
32
|
+
const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : undefined;
|
|
33
|
+
const report = await decideExtensionReadiness(target, { cdpPort });
|
|
34
|
+
return {
|
|
35
|
+
decision: report.decision,
|
|
36
|
+
reasonCode: report.reasonCode,
|
|
37
|
+
reasons: report.reasons,
|
|
38
|
+
deps: report.checks.deps.status,
|
|
39
|
+
devServer: { label: 'webpack', status: watcherStatus(report.checks.buildLog.status) },
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
devServer: {
|
|
44
|
+
describe: () => 'webpack watcher',
|
|
45
|
+
stop(target: string): AdapterDevServerStop {
|
|
46
|
+
const signalled = stopExtensionWatcher(target);
|
|
47
|
+
// Best-effort: close the webpack tail window this checkout's watcher owned.
|
|
48
|
+
const port = process.env.WATCHER_PORT ?? 'default';
|
|
49
|
+
spawnSync('tmux', ['kill-window', '-t', `webpack-${port}`], { stdio: 'ignore', timeout: 2000 });
|
|
50
|
+
const summary = signalled > 0
|
|
51
|
+
? `stopped webpack watcher (${signalled} process${signalled === 1 ? '' : 'es'}) for ${target}`
|
|
52
|
+
: `webpack watcher not running for ${target} — nothing to stop`;
|
|
53
|
+
return { kind: 'stopped', status: 0, summary, signalled };
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
logSources(target: string): AdapterLogSource[] {
|
|
58
|
+
// Most-relevant first: the live webpack log, the harness-owned watcher log,
|
|
59
|
+
// then the quick-relaunch rebuild log. `logs` tails the first that exists.
|
|
60
|
+
return [
|
|
61
|
+
{ label: 'webpack', path: recipeRuntimePath(target, 'webpack.log') },
|
|
62
|
+
{ label: 'watcher', path: recipeRuntimePath(target, 'recipe-harness-webpack.log') },
|
|
63
|
+
{ label: 'rebuild', path: recipeRuntimePath(target, 'rebuild.log') },
|
|
64
|
+
];
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
hints: {
|
|
68
|
+
launch: 'mm-harness launch',
|
|
69
|
+
relaunch: 'mm-harness launch --build',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
|
+
import { recordDepsBaseline } from '@farmslot/recipe-harness/runtime/deps-readiness';
|
|
11
|
+
|
|
10
12
|
import { EXIT, spawnScriptStreaming } from '../../commands/shared.ts';
|
|
11
13
|
import type { ScriptResult } from '../../commands/shared.ts';
|
|
12
14
|
import { runnerDir } from '../../paths.ts';
|
|
@@ -24,7 +26,16 @@ export { type MobileRuntimeDecisionReport };
|
|
|
24
26
|
// An inherited FORCE_COLOR makes node emit a colorized `undefined`, so VisionCamera
|
|
25
27
|
// misdetects the worklets pod, enables FrameProcessors, and fails on the missing
|
|
26
28
|
// pod. Force plain output for pod-triggering spawns so the probe reads `undefined`.
|
|
27
|
-
|
|
29
|
+
// CocoaPods reads UTF-8 podspecs; a parent env without a locale (gateway/tmux
|
|
30
|
+
// spawns) makes Ruby parse them as US-ASCII and die on the first multibyte byte
|
|
31
|
+
// ('"\xE2" on US-ASCII' in react-native-mmkv.podspec). Pin a UTF-8 locale for
|
|
32
|
+
// pod-triggering spawns when the caller has none.
|
|
33
|
+
const POD_PROBE_ENV: Record<string, string> = {
|
|
34
|
+
FORCE_COLOR: '0',
|
|
35
|
+
NO_COLOR: '1',
|
|
36
|
+
LANG: process.env.LANG?.includes('UTF-8') ? process.env.LANG : 'en_US.UTF-8',
|
|
37
|
+
LC_ALL: process.env.LC_ALL?.includes('UTF-8') ? process.env.LC_ALL : 'en_US.UTF-8',
|
|
38
|
+
};
|
|
28
39
|
|
|
29
40
|
export interface PrepareMobileOptions extends MobileRuntimeDecisionOptions {
|
|
30
41
|
/**
|
|
@@ -59,6 +70,7 @@ export async function mobileRuntimeStatus(
|
|
|
59
70
|
metroLog: opts.metroLog,
|
|
60
71
|
platform: opts.platform,
|
|
61
72
|
record: opts.record,
|
|
73
|
+
preflightMode: opts.preflightMode,
|
|
62
74
|
});
|
|
63
75
|
}
|
|
64
76
|
|
|
@@ -81,11 +93,13 @@ export async function prepareMobile(
|
|
|
81
93
|
metroLog: opts.metroLog,
|
|
82
94
|
platform,
|
|
83
95
|
record: opts.record,
|
|
96
|
+
preflightMode,
|
|
84
97
|
});
|
|
85
98
|
|
|
86
99
|
if (report.decision === 'blocked') {
|
|
87
100
|
const reasons = report.reasons.join(' ');
|
|
88
|
-
const
|
|
101
|
+
const next = report.userAction ?? 'fix the bundle error in app code before retrying recipe up.';
|
|
102
|
+
const msg = `mobile prepare blocked: ${reasons}\n Next: ${next}`;
|
|
89
103
|
if (!json) process.stderr.write(`${msg}\n`);
|
|
90
104
|
return { status: EXIT.runtime, output: msg };
|
|
91
105
|
}
|
|
@@ -112,6 +126,11 @@ export async function prepareMobile(
|
|
|
112
126
|
for (const action of actions) {
|
|
113
127
|
const result = await dispatchAction(action, target, platform, json, preflightMode);
|
|
114
128
|
if (result.status !== 0) return result;
|
|
129
|
+
// Record the deps baseline the instant a setup/install leaf succeeds, so the
|
|
130
|
+
// mtime freshness fallback stops firing forever after one good install. This
|
|
131
|
+
// is unconditional (not gated on a later re-decide or an env flag), so the
|
|
132
|
+
// baseline lifecycle is self-sustaining regardless of the caller's path.
|
|
133
|
+
if (action.id === 'yarn-setup') recordDepsBaseline(path.resolve(target));
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
// After install: re-decide and run appropriate actions.
|
|
@@ -122,7 +141,7 @@ export async function prepareMobile(
|
|
|
122
141
|
watcherPort: opts.watcherPort,
|
|
123
142
|
metroLog: opts.metroLog,
|
|
124
143
|
platform,
|
|
125
|
-
|
|
144
|
+
preflightMode,
|
|
126
145
|
});
|
|
127
146
|
switch (postInstall.decision) {
|
|
128
147
|
case 'install': {
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Mobile surface: delegates to the existing mobile readiness/port plumbing.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { recipeRuntimePath, runnerDir } from '../../paths.ts';
|
|
6
|
+
import { resolveMobileSlotPorts } from '../slot-ports.ts';
|
|
7
|
+
import { mobileRuntimeStatus } from './prepare.ts';
|
|
8
|
+
import type {
|
|
9
|
+
AdapterDevServerStop,
|
|
10
|
+
AdapterLogSource,
|
|
11
|
+
AdapterRuntimeStatus,
|
|
12
|
+
AdapterSurface,
|
|
13
|
+
} from '../surface.ts';
|
|
14
|
+
|
|
15
|
+
export const mobileSurface: AdapterSurface = {
|
|
16
|
+
adapter: 'mobile',
|
|
17
|
+
headless: false,
|
|
18
|
+
|
|
19
|
+
resolveSlotPorts(target: string): void {
|
|
20
|
+
resolveMobileSlotPorts(target);
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async runtimeStatus(target: string): Promise<AdapterRuntimeStatus> {
|
|
24
|
+
const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : undefined;
|
|
25
|
+
const report = await mobileRuntimeStatus(target, { watcherPort });
|
|
26
|
+
return {
|
|
27
|
+
decision: report.decision,
|
|
28
|
+
reasonCode: report.reasonCode,
|
|
29
|
+
reasons: report.reasons,
|
|
30
|
+
deps: report.checks?.deps?.status,
|
|
31
|
+
devServer: { label: 'metro', status: report.checks?.metro?.status ?? 'unprobed' },
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
devServer: {
|
|
36
|
+
describe: () => 'Metro dev server',
|
|
37
|
+
stop(target: string): AdapterDevServerStop {
|
|
38
|
+
const leaf = path.join(runnerDir, 'adapters', 'mobile', 'stop-metro.sh');
|
|
39
|
+
const args = ['--target', target];
|
|
40
|
+
if (process.env.WATCHER_PORT) args.push('--port', process.env.WATCHER_PORT);
|
|
41
|
+
const result = spawnSync('bash', [leaf, ...args], { encoding: 'utf8' });
|
|
42
|
+
const status = result.status ?? 1;
|
|
43
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
|
44
|
+
const summary = status === 0
|
|
45
|
+
? `stopped Metro dev server for ${target}`
|
|
46
|
+
: `failed to stop Metro for ${target}`;
|
|
47
|
+
return { kind: 'stopped', status, summary, output };
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
logSources(target: string): AdapterLogSource[] {
|
|
52
|
+
return [{ label: 'metro', path: recipeRuntimePath(target, 'metro.log') }];
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
hints: {
|
|
56
|
+
launch: 'mm-harness launch ios',
|
|
57
|
+
relaunch: 'mm-harness launch ios',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Slot-port + dev-server process plumbing shared by the per-platform surfaces.
|
|
2
|
+
// Re-homed here (out of commands/launch.ts) so the surface implementations own
|
|
3
|
+
// port/device resolution and the extension watcher-stop without launch.ts and
|
|
4
|
+
// the surface registry forming an import cycle.
|
|
5
|
+
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { readRuntimeContextField, resolveRuntimeContextPath } from '../harness.ts';
|
|
11
|
+
import { recipeRuntimeDir, runnerDir } from '../paths.ts';
|
|
12
|
+
|
|
13
|
+
// Apply KEY=VALUE lines from slot resolution to process.env.
|
|
14
|
+
// overwrite=true → pool/context match, always overrides existing env.
|
|
15
|
+
// overwrite=false → formula match, only fills vars that are unset.
|
|
16
|
+
export function applyKVLines(output: string, overwrite: boolean): void {
|
|
17
|
+
for (const line of output.split('\n')) {
|
|
18
|
+
const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
|
|
19
|
+
if (!m) continue;
|
|
20
|
+
const [, key, val] = m;
|
|
21
|
+
switch (key) {
|
|
22
|
+
case 'WATCHER_PORT':
|
|
23
|
+
if (overwrite || !process.env['WATCHER_PORT']) {
|
|
24
|
+
process.env['WATCHER_PORT'] = val;
|
|
25
|
+
process.env['METRO_PORT'] = val;
|
|
26
|
+
process.env['RECIPE_WATCHER_PORT'] = val;
|
|
27
|
+
}
|
|
28
|
+
break;
|
|
29
|
+
case 'IOS_SIMULATOR':
|
|
30
|
+
if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
|
|
31
|
+
break;
|
|
32
|
+
case 'SLOT_ID':
|
|
33
|
+
if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
|
|
34
|
+
break;
|
|
35
|
+
case 'CDP_PORT':
|
|
36
|
+
if (overwrite || !process.env['CDP_PORT']) {
|
|
37
|
+
process.env['CDP_PORT'] = val;
|
|
38
|
+
process.env['RECIPE_CDP_PORT'] = val;
|
|
39
|
+
}
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sourceResolve(fn: string, target: string): string {
|
|
46
|
+
const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
|
|
47
|
+
try {
|
|
48
|
+
return execFileSync('bash', ['-c', `source "${resolveScript}" && ${fn} "${target}"`], {
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
timeout: 5000,
|
|
51
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve mobile slot port/simulator: the slot context the orchestrator wrote
|
|
59
|
+
// into the checkout wins first, then the farmslot pool (both overwrite env),
|
|
60
|
+
// then the slot-suffix formula (only fills unset vars). Called before explicit
|
|
61
|
+
// CLI flag overrides so flags always win at the top.
|
|
62
|
+
export function resolveMobileSlotPorts(target: string): void {
|
|
63
|
+
// The checkout's own runtime context is authoritative — it names the exact
|
|
64
|
+
// simulator/port this slot was prepared with, surviving pool renames.
|
|
65
|
+
const ctxOut = sourceResolve('resolve_mobile_runtime_context', target);
|
|
66
|
+
if (ctxOut.trim()) {
|
|
67
|
+
applyKVLines(ctxOut, true);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Pool match always wins — overwrite whatever is in the environment.
|
|
71
|
+
const poolOut = sourceResolve('resolve_farmslot_ports_by_repo', target);
|
|
72
|
+
if (poolOut.trim()) {
|
|
73
|
+
applyKVLines(poolOut, true);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Formula match only fills unset vars (never overrides explicit env/pool).
|
|
77
|
+
const defOut = sourceResolve('resolve_mobile_slot_defaults', target);
|
|
78
|
+
if (defOut.trim()) applyKVLines(defOut, false);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Resolve extension slot ports the same way as mobile: the checkout's runtime
|
|
82
|
+
// context first (cdpPort/devServerPort written by the orchestrator's prepare),
|
|
83
|
+
// then the farmslot pool, then the directory-suffix formula (fills unset only).
|
|
84
|
+
export function resolveExtensionSlotPorts(target: string): void {
|
|
85
|
+
// The prepared checkout's context OVERWRITES inherited env (same authority as
|
|
86
|
+
// mobile's context/pool resolution): a stale CDP_PORT from the shell must not
|
|
87
|
+
// hijack the slot's browser. Explicit CLI flags are applied after and win.
|
|
88
|
+
const contextPath = resolveRuntimeContextPath(target);
|
|
89
|
+
const cdp = readRuntimeContextField(contextPath, 'cdpPort');
|
|
90
|
+
if (cdp) {
|
|
91
|
+
process.env['CDP_PORT'] = cdp;
|
|
92
|
+
process.env['RECIPE_CDP_PORT'] = cdp;
|
|
93
|
+
}
|
|
94
|
+
const dev = readRuntimeContextField(contextPath, 'devServerPort');
|
|
95
|
+
if (dev) {
|
|
96
|
+
process.env['WATCHER_PORT'] = dev;
|
|
97
|
+
process.env['RECIPE_WATCHER_PORT'] = dev;
|
|
98
|
+
}
|
|
99
|
+
if (process.env['CDP_PORT']) return;
|
|
100
|
+
const poolOut = sourceResolve('resolve_farmslot_ports_by_repo', target);
|
|
101
|
+
if (poolOut.trim()) {
|
|
102
|
+
applyKVLines(poolOut, true);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const defOut = sourceResolve('resolve_default_extension_ports', target);
|
|
106
|
+
if (defOut.trim()) applyKVLines(defOut, false);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Kill the harness-owned webpack watcher for this checkout: pid file first,
|
|
110
|
+
// then a ps-scan for orphans (argv or lsof-cwd match), TERM then KILL. Scoped
|
|
111
|
+
// to the target checkout — watchers of other slots are never touched. Returns
|
|
112
|
+
// how many processes were signalled so callers can state the outcome.
|
|
113
|
+
export function stopExtensionWatcher(target: string): number {
|
|
114
|
+
const runtimeAbs = path.join(target, recipeRuntimeDir());
|
|
115
|
+
const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
|
|
116
|
+
let signalled = 0;
|
|
117
|
+
try {
|
|
118
|
+
const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
|
|
119
|
+
if (/^\d+$/u.test(pid)) {
|
|
120
|
+
try { process.kill(Number(pid), 'SIGTERM'); signalled += 1; } catch { /* already dead */ }
|
|
121
|
+
}
|
|
122
|
+
fs.rmSync(webpackPidFile, { force: true });
|
|
123
|
+
} catch { /* no pid file */ }
|
|
124
|
+
// Scan for any remaining orphan webpack/yarn-start processes in this checkout.
|
|
125
|
+
try {
|
|
126
|
+
const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
|
|
127
|
+
const orphanPids: number[] = [];
|
|
128
|
+
for (const line of psOut.split('\n')) {
|
|
129
|
+
const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
|
|
130
|
+
if (!match) continue;
|
|
131
|
+
const [, pidStr, cmd] = match;
|
|
132
|
+
const isWatcher =
|
|
133
|
+
cmd.includes('yarn start') ||
|
|
134
|
+
cmd.includes('webpack --watch') ||
|
|
135
|
+
cmd.includes('development/webpack/launch.ts --watch');
|
|
136
|
+
if (!isWatcher) continue;
|
|
137
|
+
if (cmd.includes(target)) {
|
|
138
|
+
orphanPids.push(Number(pidStr));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// lsof cwd fallback for processes that don't embed the path in argv.
|
|
142
|
+
try {
|
|
143
|
+
const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
timeout: 2000,
|
|
146
|
+
});
|
|
147
|
+
if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
|
|
148
|
+
orphanPids.push(Number(pidStr));
|
|
149
|
+
}
|
|
150
|
+
} catch { /* lsof unavailable or permission denied */ }
|
|
151
|
+
}
|
|
152
|
+
if (orphanPids.length > 0) {
|
|
153
|
+
for (const pid of orphanPids) {
|
|
154
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
155
|
+
}
|
|
156
|
+
// Brief pause then force-kill survivors.
|
|
157
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
158
|
+
for (const pid of orphanPids) {
|
|
159
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
|
|
160
|
+
}
|
|
161
|
+
signalled += orphanPids.length;
|
|
162
|
+
}
|
|
163
|
+
} catch { /* ps not available */ }
|
|
164
|
+
return signalled;
|
|
165
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The per-platform surface: one interface every command resolves platform
|
|
2
|
+
// behavior through, so no command hand-rolls `if (adapter === …)` for behavior a
|
|
3
|
+
// platform owns. A command NEVER branches on adapter for behavior this interface
|
|
4
|
+
// owns; a new platform behavior extends the surface (a new member here + its
|
|
5
|
+
// three implementations), not the command. This is the enforcement mechanism for
|
|
6
|
+
// UX-PRINCIPLES.md principle 1 (context-aware by default). See
|
|
7
|
+
// docs/ADAPTER-SURFACE.md.
|
|
8
|
+
|
|
9
|
+
import type { MetaMaskRecipeAdapter } from '../types.ts';
|
|
10
|
+
import { coreSurface } from './core/surface.ts';
|
|
11
|
+
import { extensionSurface } from './extension/surface.ts';
|
|
12
|
+
import { mobileSurface } from './mobile/surface.ts';
|
|
13
|
+
|
|
14
|
+
// Read-only runtime readiness, normalized across platforms so `doctor` renders
|
|
15
|
+
// one line the same way regardless of adapter. Device platforms fill deps +
|
|
16
|
+
// devServer; core (headless) reports deps presence only (no devServer).
|
|
17
|
+
export interface AdapterRuntimeStatus {
|
|
18
|
+
decision: string;
|
|
19
|
+
reasonCode?: string;
|
|
20
|
+
reasons: string[];
|
|
21
|
+
deps?: string;
|
|
22
|
+
// The platform's dev server (Metro for mobile, webpack watcher for extension),
|
|
23
|
+
// labelled so the render names the right thing. Absent for headless core.
|
|
24
|
+
devServer?: { label: string; status: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One log file a platform writes, most-relevant first. `logs` tails the first
|
|
28
|
+
// candidate that exists.
|
|
29
|
+
export interface AdapterLogSource {
|
|
30
|
+
label: string;
|
|
31
|
+
path: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Result of stopping the platform's dev server. `headless` is the core case —
|
|
35
|
+
// there is nothing to stop, so `stop` teaches instead. `stopped` carries the
|
|
36
|
+
// exit status plus, when the platform counts them, how many processes were
|
|
37
|
+
// signalled and the raw leaf output for the --json envelope.
|
|
38
|
+
export type AdapterDevServerStop =
|
|
39
|
+
| { kind: 'headless'; message: string; userAction: string }
|
|
40
|
+
| { kind: 'stopped'; status: number; summary: string; signalled?: number; output?: string };
|
|
41
|
+
|
|
42
|
+
export interface AdapterDevServer {
|
|
43
|
+
// One noun for the dev server this platform runs ("Metro", "webpack watcher").
|
|
44
|
+
describe(): string;
|
|
45
|
+
// Stop the dev server this checkout owns, port/pid-scoped. Idempotent:
|
|
46
|
+
// nothing-to-stop is success, never an error.
|
|
47
|
+
stop(target: string): AdapterDevServerStop;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Platform-phrased Next: hints so no command prints another platform's vocabulary.
|
|
51
|
+
// `launch` (re)starts the app + dev server; `relaunch` rebuilds first (the
|
|
52
|
+
// fixtures-set retry). core has no app, so its hints teach the headless path.
|
|
53
|
+
export interface AdapterHints {
|
|
54
|
+
launch: string;
|
|
55
|
+
relaunch: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AdapterSurface {
|
|
59
|
+
readonly adapter: MetaMaskRecipeAdapter;
|
|
60
|
+
// core runs no app/dev server; device adapters (mobile/extension) do. Commands
|
|
61
|
+
// ask this instead of testing `adapter === 'core'`.
|
|
62
|
+
readonly headless: boolean;
|
|
63
|
+
// Resolve slot ports/device into the environment (checkout context > pool >
|
|
64
|
+
// formula). No-op for core.
|
|
65
|
+
resolveSlotPorts(target: string): void;
|
|
66
|
+
// Read-only readiness for `doctor`. Never launches or mutates.
|
|
67
|
+
runtimeStatus(target: string): Promise<AdapterRuntimeStatus>;
|
|
68
|
+
devServer: AdapterDevServer;
|
|
69
|
+
// Ordered candidate log files for `logs`. Empty for core.
|
|
70
|
+
logSources(target: string): AdapterLogSource[];
|
|
71
|
+
hints: AdapterHints;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SURFACES: Record<MetaMaskRecipeAdapter, AdapterSurface> = {
|
|
75
|
+
mobile: mobileSurface,
|
|
76
|
+
extension: extensionSurface,
|
|
77
|
+
core: coreSurface,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function getAdapterSurface(adapter: MetaMaskRecipeAdapter): AdapterSurface {
|
|
81
|
+
return SURFACES[adapter];
|
|
82
|
+
}
|