@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
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,
|
|
@@ -27,6 +28,8 @@ import { ensureExtensionReady } from './adapters/extension/ensure-ready.ts';
|
|
|
27
28
|
import { resolveExtensionId } from './adapters/extension/extension-id.ts';
|
|
28
29
|
import { decideExtensionReadiness } from './adapters/extension/runtime-decision.ts';
|
|
29
30
|
import { decideMobileReadiness } from './adapters/mobile/runtime-decision.ts';
|
|
31
|
+
import { getAdapterSurface } from './adapters/surface.ts';
|
|
32
|
+
import type { AdapterRuntimeStatus } from './adapters/surface.ts';
|
|
30
33
|
// NOTE: extension-runtime.ts loads the recipe harness at module scope, so it
|
|
31
34
|
// is imported LAZILY (dynamic import) only inside the handlers that drive a live
|
|
32
35
|
// runtime. Static-import it here and every command — manifest, doctor,
|
|
@@ -420,7 +423,7 @@ async function runSelfTest(options: CliOptions) {
|
|
|
420
423
|
}
|
|
421
424
|
|
|
422
425
|
async function handleManifest({ options }: ParsedArgs): Promise<number> {
|
|
423
|
-
const adapter =
|
|
426
|
+
const { adapter } = resolveAdapter(options);
|
|
424
427
|
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
425
428
|
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
426
429
|
await validateManifest(manifest);
|
|
@@ -430,7 +433,7 @@ async function handleManifest({ options }: ParsedArgs): Promise<number> {
|
|
|
430
433
|
}
|
|
431
434
|
|
|
432
435
|
async function handleActions({ options }: ParsedArgs): Promise<number> {
|
|
433
|
-
const adapter =
|
|
436
|
+
const { adapter } = resolveAdapter(options);
|
|
434
437
|
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
435
438
|
await validateManifest(manifest);
|
|
436
439
|
const action = optionString(options, 'action');
|
|
@@ -475,9 +478,31 @@ async function handleDoctor({ options }: ParsedArgs): Promise<number> {
|
|
|
475
478
|
}
|
|
476
479
|
|
|
477
480
|
const result = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
|
|
478
|
-
|
|
481
|
+
// Live runtime state, per platform, via the adapter surface: every adapter
|
|
482
|
+
// reports the same read-only readiness shape (decision + reasons, deps currency,
|
|
483
|
+
// and its dev server where it has one) so doctor renders one line the same way
|
|
484
|
+
// for mobile, extension, and core. doctor never branches on adapter for this.
|
|
485
|
+
let runtime: AdapterRuntimeStatus | undefined;
|
|
486
|
+
try {
|
|
487
|
+
const surface = getAdapterSurface(adapter);
|
|
488
|
+
surface.resolveSlotPorts(target);
|
|
489
|
+
runtime = await surface.runtimeStatus(target);
|
|
490
|
+
} catch { /* readiness probe unavailable — doctor stays useful without it */ }
|
|
491
|
+
if (json) console.log(JSON.stringify({ ...result, runtime }, null, 2));
|
|
479
492
|
else {
|
|
480
|
-
|
|
493
|
+
const out = (style: string, text: string) => color(style, text, { stream: process.stdout });
|
|
494
|
+
const stateStyle = (value: string | undefined, good: string) => (value === good ? 'ok' : 'warn');
|
|
495
|
+
console.log(`${out(result.status === 'pass' ? 'ok' : 'err', result.status)} ${out('bold', adapter)} ${result.compatibilityMode} ${out('dim', `manifest=${actionManifestPath}`)}`);
|
|
496
|
+
if (runtime) {
|
|
497
|
+
const decisionStyle = runtime.decision === 'ready' ? 'ok' : runtime.decision === 'blocked' ? 'err' : 'warn';
|
|
498
|
+
const devServer = runtime.devServer
|
|
499
|
+
? ` ${runtime.devServer.label}=${out(stateStyle(runtime.devServer.status, 'up'), runtime.devServer.status)}`
|
|
500
|
+
: '';
|
|
501
|
+
console.log(
|
|
502
|
+
`${out('label', 'runtime:')} decision=${out(decisionStyle, runtime.decision)}${runtime.reasonCode ? ` ${out('dim', `(${runtime.reasonCode})`)}` : ''} deps=${out(stateStyle(runtime.deps, 'current'), runtime.deps ?? 'unknown')}${devServer}`,
|
|
503
|
+
);
|
|
504
|
+
for (const reason of runtime.reasons) console.log(` ${out('dim', reason)}`);
|
|
505
|
+
}
|
|
481
506
|
console.log(renderRuntimeContext(result.runtimeContext));
|
|
482
507
|
}
|
|
483
508
|
return result.status === 'pass' ? 0 : 1;
|
|
@@ -758,6 +783,7 @@ async function handleRuntimeDecision({ options }: ParsedArgs): Promise<number> {
|
|
|
758
783
|
metroLog: optionString(options, 'metroLog'),
|
|
759
784
|
platform: optionString(options, 'platform') ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
|
|
760
785
|
record: optionFlag(options, 'record'),
|
|
786
|
+
preflightMode: optionString(options, 'preflightMode'),
|
|
761
787
|
});
|
|
762
788
|
if (optionFlag(options, 'json')) console.log(JSON.stringify(report, null, 2));
|
|
763
789
|
else console.log(`${report.decision} ${report.reasonCode} — ${report.reasons[0] ?? ''}`);
|
|
@@ -1082,6 +1108,51 @@ function emitPlanUsageError(
|
|
|
1082
1108
|
// hand it to runRecipe). One execution path, two doors: `call` = one node, `run` =
|
|
1083
1109
|
// a graph. Inherits run semantics: always-validates (adapter-aware, exit 5), same
|
|
1084
1110
|
// trace/evidence artifacts, same --json contract.
|
|
1111
|
+
async function handleStop(argv: string[]): Promise<number> {
|
|
1112
|
+
const { options } = parseArgs(argv, 'stop');
|
|
1113
|
+
const json = optionFlag(options, 'json');
|
|
1114
|
+
const { adapter, target } = resolveAdapter(options);
|
|
1115
|
+
const surface = getAdapterSurface(adapter);
|
|
1116
|
+
// Slot-scope the stop: an explicit --port wins, else resolve the checkout's
|
|
1117
|
+
// own dev-server port so a concurrent slot's server is never signalled.
|
|
1118
|
+
const explicitPort = optionString(options, 'port') ?? optionString(options, 'watcherPort');
|
|
1119
|
+
if (explicitPort) {
|
|
1120
|
+
process.env.WATCHER_PORT = explicitPort;
|
|
1121
|
+
process.env.METRO_PORT = explicitPort;
|
|
1122
|
+
} else {
|
|
1123
|
+
surface.resolveSlotPorts(target);
|
|
1124
|
+
}
|
|
1125
|
+
const stop = surface.devServer.stop(target);
|
|
1126
|
+
if (stop.kind === 'headless') {
|
|
1127
|
+
// core has no dev server — teach the reachable headless path (exit 2).
|
|
1128
|
+
return usageOut(json, 'stop', stop.message, stop.userAction);
|
|
1129
|
+
}
|
|
1130
|
+
if (json) {
|
|
1131
|
+
console.log(
|
|
1132
|
+
JSON.stringify(
|
|
1133
|
+
{
|
|
1134
|
+
schemaVersion: 1,
|
|
1135
|
+
command: 'stop',
|
|
1136
|
+
adapter,
|
|
1137
|
+
target,
|
|
1138
|
+
status: stop.status === 0 ? 'pass' : 'fail',
|
|
1139
|
+
...(stop.signalled !== undefined ? { signalled: stop.signalled } : {}),
|
|
1140
|
+
exitCode: stop.status,
|
|
1141
|
+
...(stop.output ? { output: stop.output } : {}),
|
|
1142
|
+
},
|
|
1143
|
+
null,
|
|
1144
|
+
2,
|
|
1145
|
+
),
|
|
1146
|
+
);
|
|
1147
|
+
} else {
|
|
1148
|
+
// The leaf may have captured its own detail; surface it, then the uniform
|
|
1149
|
+
// outcome line the same way for every platform.
|
|
1150
|
+
if (stop.output) process.stderr.write(`${stop.output}\n`);
|
|
1151
|
+
console.error(`${color(stop.status === 0 ? 'ok' : 'err', stop.status === 0 ? '✓' : '✗')} ${stop.summary}`);
|
|
1152
|
+
}
|
|
1153
|
+
return stop.status;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1085
1156
|
async function handleCall(argv: string[]): Promise<number> {
|
|
1086
1157
|
// Grammar: mm-harness call <action> [--arg k=v ...] [flags].
|
|
1087
1158
|
// If the first token is a flag, the action positional is missing — parseCallArgs
|
|
@@ -1096,9 +1167,23 @@ async function handleCall(argv: string[]): Promise<number> {
|
|
|
1096
1167
|
const { options } = parseArgs(rest, 'call');
|
|
1097
1168
|
const json = optionFlag(options, 'json');
|
|
1098
1169
|
if (!shortName) {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1170
|
+
// Context-aware usage: the example uses the DETECTED adapter and a REAL action
|
|
1171
|
+
// from its manifest — never a hardcoded action/adapter that may not exist in
|
|
1172
|
+
// this checkout — and points at the scoped discovery command.
|
|
1173
|
+
let example = 'mm-harness call <action>';
|
|
1174
|
+
let discovery = 'mm-harness actions';
|
|
1175
|
+
try {
|
|
1176
|
+
const { adapter } = resolveAdapter(options);
|
|
1177
|
+
const manifest = loadActionManifest(adapter, optionString(options, 'actionManifest'));
|
|
1178
|
+
const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
|
|
1179
|
+
const exampleAction = pickCallExampleAction(getRecipeActionManifestActionNames(manifest));
|
|
1180
|
+
example = `mm-harness call ${exampleAction} --adapter ${adapter}`;
|
|
1181
|
+
discovery = `mm-harness actions --adapter ${adapter}`;
|
|
1182
|
+
} catch { /* adapter/manifest unavailable — keep the generic example */ }
|
|
1183
|
+
const message = `call requires <action>. Example: ${example}`;
|
|
1184
|
+
const userAction = `${example} # see the vocabulary: ${discovery}`;
|
|
1185
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: 'call', error: { code: 'USAGE', message, userAction } }, null, 2));
|
|
1186
|
+
else console.error(`${message}\n See the vocabulary: ${discovery}`);
|
|
1102
1187
|
return EXIT.usage;
|
|
1103
1188
|
}
|
|
1104
1189
|
|
|
@@ -1268,6 +1353,16 @@ function resolveActionName(shortName: string, names: string[]): ActionResolution
|
|
|
1268
1353
|
return { status: 'unknown', resolved: '', candidates: [] };
|
|
1269
1354
|
}
|
|
1270
1355
|
|
|
1356
|
+
// A real action name to show in the `call` usage example, from THIS adapter's
|
|
1357
|
+
// manifest: a wallet action when the platform has one, else the universal
|
|
1358
|
+
// `command` action, else the first declared name. Never a hardcoded guess.
|
|
1359
|
+
function pickCallExampleAction(names: string[]): string {
|
|
1360
|
+
const walletish = names.find((name) => /wallet|unlock/u.test(name));
|
|
1361
|
+
if (walletish) return walletish;
|
|
1362
|
+
if (names.includes('command')) return 'command';
|
|
1363
|
+
return names[0] ?? 'command';
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1271
1366
|
function synthesizeOneNodeRecipe(action: string, args: Record<string, string>): Record<string, unknown> {
|
|
1272
1367
|
return {
|
|
1273
1368
|
schema_version: 1,
|
|
@@ -1722,6 +1817,16 @@ function serializeLibrarySources(sources: MetaMaskLibrarySource[]): string {
|
|
|
1722
1817
|
.join(':');
|
|
1723
1818
|
}
|
|
1724
1819
|
|
|
1820
|
+
// Map a resolved library source to its precedence tier for the flows legend, from
|
|
1821
|
+
// the ACTUAL resolution rather than an invented label: the canonical MetaMask
|
|
1822
|
+
// library this runner appends last, the personal library the harness names
|
|
1823
|
+
// 'personal', and any other configured library in between as the team/shared tier.
|
|
1824
|
+
function flowSourceTier(source: MetaMaskLibrarySource, index: number, total: number): string {
|
|
1825
|
+
if (index === total - 1 && source.name === 'metamask') return 'canonical';
|
|
1826
|
+
if (source.name === 'personal') return 'personal';
|
|
1827
|
+
return 'team';
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1725
1830
|
// The engine's flows subcommands (grounded in registerFlowsCommand): a bare
|
|
1726
1831
|
// `flows` (or one that leads with a flag) means list.
|
|
1727
1832
|
const FLOWS_SUBCOMMANDS: readonly string[] = ['list', 'promote'];
|
|
@@ -1752,11 +1857,25 @@ async function handleFlows(argv: string[]): Promise<number> {
|
|
|
1752
1857
|
const sources = await resolveMetaMaskLibrarySources(undefined);
|
|
1753
1858
|
if (!sources) {
|
|
1754
1859
|
throw usageError(
|
|
1755
|
-
'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support
|
|
1860
|
+
'flows requires @farmslot/recipe-harness >= 0.3.3 (recipe-library support; ' +
|
|
1861
|
+
'flows resolve personal > team > canonical). ' +
|
|
1756
1862
|
'Next: update the dependency and run yarn install.',
|
|
1757
1863
|
);
|
|
1758
1864
|
}
|
|
1759
1865
|
const forwarded = stripTargetFlag(argv);
|
|
1866
|
+
const jsonMode = forwarded.includes('--json');
|
|
1867
|
+
// Provenance: flows resolve by precedence — the highest-precedence copy of a ref
|
|
1868
|
+
// wins and shadows lower ones, so a LOCAL library outranks canonical. The listing
|
|
1869
|
+
// below prints `source=<name>` per flow; this legend maps each resolved source to
|
|
1870
|
+
// its tier so that source is decodable. --json reserves stdout for the engine
|
|
1871
|
+
// envelope, so the legend rides stderr only.
|
|
1872
|
+
if (!jsonMode) {
|
|
1873
|
+
console.error(color('label', 'flows resolve by precedence (highest wins; local shadows canonical):'));
|
|
1874
|
+
sources.forEach((source, index) => {
|
|
1875
|
+
const name = source.name ?? path.basename(source.root);
|
|
1876
|
+
console.error(` ${index + 1}. ${color('cmd', name)} ${color('dim', `[${flowSourceTier(source, index, sources.length)}]`)}`);
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1760
1879
|
// The engine requires an explicit subcommand. Inspect only the FIRST token: a
|
|
1761
1880
|
// real subcommand there is passed through; anything else (empty, or a leading
|
|
1762
1881
|
// flag whose VALUE must not be mistaken for a subcommand) defaults to list.
|
|
@@ -1803,6 +1922,7 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
1803
1922
|
// launch/logs/debug/fixtures compose adapter scripts directly and own policy,
|
|
1804
1923
|
// healing, teaching, and the --json contract.
|
|
1805
1924
|
if (command === 'launch') return handleLaunch(argv.slice(1));
|
|
1925
|
+
if (command === 'stop') return handleStop(argv.slice(1));
|
|
1806
1926
|
if (command === 'logs') return handleLogs(argv.slice(1));
|
|
1807
1927
|
if (command === 'debug') return handleDebug(argv.slice(1));
|
|
1808
1928
|
if (command === 'fixtures') return handleFixtures(argv.slice(1), { runOneNode });
|
package/src/commands/debug.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
import { runnerDir } from '../paths.ts';
|
|
8
|
+
import { getAdapterSurface } from '../adapters/surface.ts';
|
|
8
9
|
import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, targetOf, usageOut } from './shared.ts';
|
|
9
10
|
|
|
10
11
|
const DEBUG_BOOLEANS = new Set(['worker', 'devMenu', 'json']);
|
|
@@ -16,7 +17,8 @@ export async function handleDebug(argv: string[]): Promise<number> {
|
|
|
16
17
|
const adapter = resolveAdapter(options, target);
|
|
17
18
|
|
|
18
19
|
if (!adapter) return usageOut(json, 'debug', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
19
|
-
|
|
20
|
+
const surface = getAdapterSurface(adapter);
|
|
21
|
+
if (surface.headless) return usageOut(json, 'debug', 'core is headless; there is no debug console.', surface.hints.relaunch);
|
|
20
22
|
|
|
21
23
|
const worker = flag(options, 'worker');
|
|
22
24
|
const devMenu = flag(options, 'devMenu');
|
package/src/commands/fixtures.ts
CHANGED
|
@@ -6,6 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
8
|
import { runnerDir, walletFixturePath } from '../paths.ts';
|
|
9
|
+
import { getAdapterSurface } from '../adapters/surface.ts';
|
|
9
10
|
import type { MetaMaskRecipeAdapter } from '../types.ts';
|
|
10
11
|
import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
|
|
11
12
|
|
|
@@ -55,9 +56,13 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
|
|
|
55
56
|
if (!adapter) {
|
|
56
57
|
return usageOut(json, 'fixtures', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
+
const surface = getAdapterSurface(adapter);
|
|
60
|
+
if (surface.headless) return usageOut(json, 'fixtures', 'core is headless; it has no wallet fixture.', surface.hints.launch);
|
|
59
61
|
|
|
60
62
|
const canonicalFixture = walletFixturePath(target);
|
|
63
|
+
// Platform-phrased retry hint on a setup-wallet failure: the surface owns the
|
|
64
|
+
// (re)build/relaunch command; this command never spells out another platform's.
|
|
65
|
+
const retryHint = `${surface.hints.relaunch} # relaunch, then retry: mm-harness fixtures set`;
|
|
61
66
|
|
|
62
67
|
if (sub === 'sync') {
|
|
63
68
|
const exitCode = fixturesSync(adapter, target, json);
|
|
@@ -104,7 +109,7 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
|
|
|
104
109
|
else process.env.APP_ROOT = previousAppRoot;
|
|
105
110
|
}
|
|
106
111
|
if (!json && status === 'fail') {
|
|
107
|
-
console.error(
|
|
112
|
+
console.error(` Next: ${retryHint}`);
|
|
108
113
|
}
|
|
109
114
|
} else {
|
|
110
115
|
// Extension has no standalone set arm — reuse call's one-node machinery via
|
|
@@ -127,7 +132,7 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
|
|
|
127
132
|
else process.env.RECIPE_WALLET_FIXTURE = previousFixtureEnv;
|
|
128
133
|
}
|
|
129
134
|
if (!json && status === 'fail') {
|
|
130
|
-
console.error(
|
|
135
|
+
console.error(` Next: ${retryHint}`);
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
|
@@ -149,9 +154,7 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
|
|
|
149
154
|
error: status === 'fail' ? {
|
|
150
155
|
code: 'SETUP_WALLET_FAILED',
|
|
151
156
|
message: 'wallet fixture setup failed',
|
|
152
|
-
userAction:
|
|
153
|
-
? 'mm-harness launch ios # relaunch the dev client, then retry: mm-harness fixtures set'
|
|
154
|
-
: 'mm-harness launch --build # rebuild and relaunch, then retry: mm-harness fixtures set',
|
|
157
|
+
userAction: retryHint,
|
|
155
158
|
} : null,
|
|
156
159
|
},
|
|
157
160
|
null,
|
|
@@ -166,8 +169,10 @@ export async function handleFixtures(argv: string[], deps: CommandDeps): Promise
|
|
|
166
169
|
|
|
167
170
|
// Sync the overlay installation + wallet fixture for the checkout.
|
|
168
171
|
// Re-runs the adapter inject to refresh patched files, then copies the wallet
|
|
169
|
-
// fixture to the canonical runtime location via sync-wallet-fixture.sh.
|
|
170
|
-
|
|
172
|
+
// fixture to the canonical runtime location via sync-wallet-fixture.sh. Reached
|
|
173
|
+
// only for device adapters (headless core returns earlier); the adapter is
|
|
174
|
+
// forwarded verbatim to `install`.
|
|
175
|
+
function fixturesSync(adapter: MetaMaskRecipeAdapter, target: string, json: boolean): number {
|
|
171
176
|
const mmHarnessBin = path.join(runnerDir, 'bin/mm-harness');
|
|
172
177
|
const installResult = spawnScript(
|
|
173
178
|
mmHarnessBin,
|
package/src/commands/launch.ts
CHANGED
|
@@ -8,10 +8,13 @@ import { execFileSync } from 'node:child_process';
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
|
|
11
|
+
import { color } from '../cli-color.ts';
|
|
11
12
|
import { handleHarness } 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';
|
|
16
|
+
import { getAdapterSurface } from '../adapters/surface.ts';
|
|
17
|
+
import { stopExtensionWatcher } from '../adapters/slot-ports.ts';
|
|
15
18
|
import {
|
|
16
19
|
ADAPTER_DETECT_NEXT,
|
|
17
20
|
EXIT,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
parseFlags,
|
|
20
23
|
resolveAdapter,
|
|
21
24
|
spawnScript,
|
|
25
|
+
spawnScriptStreaming,
|
|
22
26
|
str,
|
|
23
27
|
targetOf,
|
|
24
28
|
usageOut,
|
|
@@ -205,56 +209,6 @@ export async function handleLaunch(argv: string[]): Promise<number> {
|
|
|
205
209
|
});
|
|
206
210
|
}
|
|
207
211
|
|
|
208
|
-
// Apply KEY=VALUE lines from slot resolution to process.env.
|
|
209
|
-
// overwrite=true → pool match, always overrides existing env.
|
|
210
|
-
// overwrite=false → formula match, only fills vars that are unset.
|
|
211
|
-
function applyKVLines(output: string, overwrite: boolean): void {
|
|
212
|
-
for (const line of output.split('\n')) {
|
|
213
|
-
const m = /^([A-Z_]+)=(.+)$/u.exec(line.trim());
|
|
214
|
-
if (!m) continue;
|
|
215
|
-
const [, key, val] = m;
|
|
216
|
-
switch (key) {
|
|
217
|
-
case 'WATCHER_PORT':
|
|
218
|
-
if (overwrite || !process.env['WATCHER_PORT']) {
|
|
219
|
-
process.env['WATCHER_PORT'] = val;
|
|
220
|
-
process.env['METRO_PORT'] = val;
|
|
221
|
-
process.env['RECIPE_WATCHER_PORT'] = val;
|
|
222
|
-
}
|
|
223
|
-
break;
|
|
224
|
-
case 'IOS_SIMULATOR':
|
|
225
|
-
if (overwrite || !process.env['IOS_SIMULATOR']) process.env['IOS_SIMULATOR'] = val;
|
|
226
|
-
break;
|
|
227
|
-
case 'SLOT_ID':
|
|
228
|
-
if (overwrite || !process.env['RECIPE_SLOT_ID']) process.env['RECIPE_SLOT_ID'] = val;
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Resolve mobile slot port/simulator from the farmslot pool (pool-wins-over-env)
|
|
235
|
-
// or from the slot-suffix formula (only fills unset vars). Called before explicit
|
|
236
|
-
// CLI flag overrides so flags always win at the top.
|
|
237
|
-
function resolveMobileSlotPorts(target: string): void {
|
|
238
|
-
const resolveScript = path.join(runnerDir, 'adapters/shared/resolve-farmslot-ports.sh');
|
|
239
|
-
try {
|
|
240
|
-
// Pool match always wins — overwrite whatever is in the environment.
|
|
241
|
-
const poolOut = execFileSync('bash', [
|
|
242
|
-
'-c', `source "${resolveScript}" && resolve_farmslot_ports_by_repo "${target}"`,
|
|
243
|
-
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
244
|
-
if (poolOut.trim()) {
|
|
245
|
-
applyKVLines(poolOut, true);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
} catch { /* no pool match — fall through to formula */ }
|
|
249
|
-
try {
|
|
250
|
-
// Formula match only fills unset vars (never overrides explicit env/pool).
|
|
251
|
-
const defOut = execFileSync('bash', [
|
|
252
|
-
'-c', `source "${resolveScript}" && resolve_mobile_slot_defaults "${target}"`,
|
|
253
|
-
], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
254
|
-
if (defOut.trim()) applyKVLines(defOut, false);
|
|
255
|
-
} catch { /* no slot suffix in dir name — stays at env defaults */ }
|
|
256
|
-
}
|
|
257
|
-
|
|
258
212
|
// Map the env-gap flags onto the env vars the leaf scripts read. For mobile, slot
|
|
259
213
|
// port/simulator defaults are resolved first (pool-wins, then formula-fills-empty)
|
|
260
214
|
// so that the harness respects slot isolation; explicit CLI flags applied below
|
|
@@ -265,9 +219,12 @@ function applyLaunchEnvOverrides(
|
|
|
265
219
|
mobileTarget: string | undefined,
|
|
266
220
|
target: string,
|
|
267
221
|
): void {
|
|
268
|
-
//
|
|
269
|
-
// the farmslot pool or directory-suffix formula BEFORE applying explicit
|
|
270
|
-
|
|
222
|
+
// Slot isolation: resolve ports/device from the checkout's own slot context,
|
|
223
|
+
// the farmslot pool, or the directory-suffix formula BEFORE applying explicit
|
|
224
|
+
// flags — extension needs this as much as mobile (CDP_PORT), so neither
|
|
225
|
+
// adapter hard-fails on a value its slot already knows. The surface owns the
|
|
226
|
+
// per-platform resolution; this command never branches on adapter for it.
|
|
227
|
+
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
271
228
|
|
|
272
229
|
const device = str(options, 'device');
|
|
273
230
|
if (device && adapter === 'mobile') {
|
|
@@ -346,69 +303,25 @@ async function executeComposition(
|
|
|
346
303
|
return extensionRebuild(target, json);
|
|
347
304
|
}
|
|
348
305
|
|
|
349
|
-
// Extension build or watch: full webpack compile via start-watch.sh.
|
|
306
|
+
// Extension build or watch: full webpack compile via start-watch.sh. Streams
|
|
307
|
+
// live — a webpack compile is minutes long and must never look hung.
|
|
350
308
|
const startWatchSh = path.join(runnerDir, 'adapters/extension/start-watch.sh');
|
|
351
309
|
const watchArgs = ['--target', target];
|
|
352
310
|
if (process.env.WATCHER_PORT) watchArgs.push('--watcher-port', process.env.WATCHER_PORT);
|
|
353
|
-
|
|
311
|
+
console.error(`→ extension ${wantWatch ? 'watch' : 'build'} — webpack :${process.env.WATCHER_PORT ?? 'default'} (output streams below)`);
|
|
312
|
+
return spawnScriptStreaming(startWatchSh, watchArgs, target);
|
|
354
313
|
}
|
|
355
314
|
|
|
356
315
|
// Extension rebuild: kill the harness-owned webpack watcher (pid file + ps-scan
|
|
357
316
|
// for orphans), clear the rebuild log, then drive the installed overlay's
|
|
358
317
|
// live.sh --start-watch and tee its output into the rebuild log.
|
|
359
|
-
function extensionRebuild(target: string, json: boolean): ScriptResult {
|
|
318
|
+
async function extensionRebuild(target: string, json: boolean): Promise<ScriptResult> {
|
|
360
319
|
const runtimeDirRel = recipeRuntimeDir();
|
|
361
320
|
const runtimeAbs = path.join(target, runtimeDirRel);
|
|
362
|
-
const webpackPidFile = path.join(runtimeAbs, 'recipe-harness-webpack.pid');
|
|
363
321
|
const rebuildLog = path.join(runtimeAbs, 'rebuild.log');
|
|
364
322
|
|
|
365
323
|
// E1a: Kill harness-owned watcher via pid file, then ps-scan for orphans.
|
|
366
|
-
|
|
367
|
-
const pid = fs.readFileSync(webpackPidFile, 'utf8').trim();
|
|
368
|
-
if (/^\d+$/u.test(pid)) {
|
|
369
|
-
try { process.kill(Number(pid), 'SIGTERM'); } catch { /* already dead */ }
|
|
370
|
-
}
|
|
371
|
-
fs.rmSync(webpackPidFile, { force: true });
|
|
372
|
-
} catch { /* no pid file */ }
|
|
373
|
-
// Scan for any remaining orphan webpack/yarn-start processes in this checkout.
|
|
374
|
-
try {
|
|
375
|
-
const psOut = execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' });
|
|
376
|
-
const orphanPids: number[] = [];
|
|
377
|
-
for (const line of psOut.split('\n')) {
|
|
378
|
-
const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
|
|
379
|
-
if (!match) continue;
|
|
380
|
-
const [, pidStr, cmd] = match;
|
|
381
|
-
const isWatcher =
|
|
382
|
-
cmd.includes('yarn start') ||
|
|
383
|
-
cmd.includes('webpack --watch') ||
|
|
384
|
-
cmd.includes('development/webpack/launch.ts --watch');
|
|
385
|
-
if (!isWatcher) continue;
|
|
386
|
-
if (cmd.includes(target)) {
|
|
387
|
-
orphanPids.push(Number(pidStr));
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
// lsof cwd fallback for processes that don't embed the path in argv.
|
|
391
|
-
try {
|
|
392
|
-
const cwd = execFileSync('lsof', ['-a', `-p${pidStr}`, '-dcwd', '-Fn'], {
|
|
393
|
-
encoding: 'utf8',
|
|
394
|
-
timeout: 2000,
|
|
395
|
-
});
|
|
396
|
-
if (cwd.split('\n').some((l) => l.startsWith('n') && l.slice(1) === target)) {
|
|
397
|
-
orphanPids.push(Number(pidStr));
|
|
398
|
-
}
|
|
399
|
-
} catch { /* lsof unavailable or permission denied */ }
|
|
400
|
-
}
|
|
401
|
-
if (orphanPids.length > 0) {
|
|
402
|
-
for (const pid of orphanPids) {
|
|
403
|
-
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
404
|
-
}
|
|
405
|
-
// Brief pause then force-kill survivors.
|
|
406
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
407
|
-
for (const pid of orphanPids) {
|
|
408
|
-
try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
} catch { /* ps not available */ }
|
|
324
|
+
stopExtensionWatcher(target);
|
|
412
325
|
|
|
413
326
|
// E1c: Clear the rebuild log (directory must exist for tee).
|
|
414
327
|
fs.mkdirSync(path.dirname(rebuildLog), { recursive: true });
|
|
@@ -418,7 +331,10 @@ function extensionRebuild(target: string, json: boolean): ScriptResult {
|
|
|
418
331
|
const liveArgs = ['--target', target, '--start-watch'];
|
|
419
332
|
if (process.env.CDP_PORT) liveArgs.push('--cdp-port', process.env.CDP_PORT);
|
|
420
333
|
if (process.env.WATCHER_PORT) liveArgs.push('--watcher-port', process.env.WATCHER_PORT);
|
|
421
|
-
|
|
334
|
+
// Streams live: the watcher restart + Chromium boot runs for minutes and the
|
|
335
|
+
// command must show progress immediately, not a silent prompt.
|
|
336
|
+
console.error(`→ extension quick relaunch — webpack :${process.env.WATCHER_PORT ?? 'default'} · CDP :${process.env.CDP_PORT ?? 'default'} (output streams below)`);
|
|
337
|
+
const result = await spawnScriptStreaming(liveScript, liveArgs, target);
|
|
422
338
|
// Tee live.sh output into rebuild.log for post-mortem diagnosis.
|
|
423
339
|
if (result.output) {
|
|
424
340
|
try { fs.appendFileSync(rebuildLog, result.output); } catch { /* best-effort */ }
|
|
@@ -489,7 +405,18 @@ function launchPass(
|
|
|
489
405
|
),
|
|
490
406
|
);
|
|
491
407
|
} else {
|
|
492
|
-
|
|
408
|
+
// The summary must say what actually happened where: the target device,
|
|
409
|
+
// what the tier meant, and that the app+bridge are up — "(quick) ready"
|
|
410
|
+
// alone reads as a no-op even when a launch occurred.
|
|
411
|
+
const device =
|
|
412
|
+
adapter === 'mobile'
|
|
413
|
+
? process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || 'booted device'
|
|
414
|
+
: displayMode;
|
|
415
|
+
const tierNote = tier === 'quick' ? 'quick relaunch, no native build' : tier;
|
|
416
|
+
const devNote = process.env.MM_HARNESS_BIN ? ` ${color('dim', '[dev: MM_HARNESS_BIN]')}` : '';
|
|
417
|
+
console.error(
|
|
418
|
+
`${color('ok', '✓')} launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ''} — ${color('ok', String(device))} · ${tierNote} · app + bridge ready${devNote}`,
|
|
419
|
+
);
|
|
493
420
|
}
|
|
494
421
|
return EXIT.ok;
|
|
495
422
|
}
|
package/src/commands/logs.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { runnerDir } from '../paths.ts';
|
|
9
|
+
import { getAdapterSurface } from '../adapters/surface.ts';
|
|
9
10
|
import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, spawnScript, str, targetOf, usageOut } from './shared.ts';
|
|
10
11
|
|
|
11
12
|
const LOGS_BOOLEANS = new Set(['full', 'json']);
|
|
@@ -19,13 +20,23 @@ export async function handleLogs(argv: string[]): Promise<number> {
|
|
|
19
20
|
if (!adapter) {
|
|
20
21
|
return usageOut(json, 'logs', `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
const surface = getAdapterSurface(adapter);
|
|
24
|
+
if (surface.headless) {
|
|
25
|
+
return usageOut(json, 'logs', 'core is headless; it has no dev server logs.', surface.hints.launch);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// Source selection is platform-scoped: the valid names and the default come
|
|
29
|
+
// from THIS adapter's dev-server logs (mobile: metro; extension: webpack), plus
|
|
30
|
+
// the app-log source. No adapter's vocabulary is hardcoded here — every
|
|
31
|
+
// non-headless adapter provides at least one log source (headless core returned
|
|
32
|
+
// above), so the default is that adapter's first source.
|
|
33
|
+
const logSources = surface.logSources(target);
|
|
34
|
+
const sourceLabels = logSources.map((entry) => entry.label);
|
|
35
|
+
const defaultSource = sourceLabels[0];
|
|
36
|
+
const source = str(options, 'source') ?? defaultSource;
|
|
37
|
+
const validSources = [...sourceLabels, 'app'];
|
|
38
|
+
if (!validSources.includes(source)) {
|
|
39
|
+
return usageOut(json, 'logs', `--source must be one of: ${validSources.join(', ')}.`, `mm-harness logs --source ${defaultSource}`);
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
// Env-gap flag (docs/CLI-SPEC.md Part 4): --events sets the compact event count
|
|
@@ -39,16 +50,21 @@ export async function handleLogs(argv: string[]): Promise<number> {
|
|
|
39
50
|
process.env.RECIPE_LOG_EVENTS = events;
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
// Nothing running → teaching error pointing at launch.
|
|
43
|
-
// signal that
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
// Nothing running → teaching error pointing at launch. A dev-server log file is
|
|
54
|
+
// the signal that the dev server has been started for this checkout; the surface
|
|
55
|
+
// owns which files a platform writes (mobile: metro.log; extension: webpack +
|
|
56
|
+
// watcher + rebuild logs). A `--source` naming a specific dev-server log is
|
|
57
|
+
// preferred; otherwise the most-relevant existing candidate is tailed.
|
|
58
|
+
const requested = logSources.find((entry) => entry.label === source);
|
|
59
|
+
const ordered = requested ? [requested, ...logSources.filter((entry) => entry !== requested)] : logSources;
|
|
60
|
+
const logFile = ordered.find((entry) => fs.existsSync(entry.path))?.path;
|
|
61
|
+
if (!logFile) {
|
|
62
|
+
const names = logSources.map((entry) => path.basename(entry.path)).join(' / ');
|
|
47
63
|
return usageOut(
|
|
48
64
|
json,
|
|
49
65
|
'logs',
|
|
50
|
-
`nothing running for this checkout (no ${
|
|
51
|
-
|
|
66
|
+
`nothing running for this checkout (no ${names}).`,
|
|
67
|
+
surface.hints.launch,
|
|
52
68
|
);
|
|
53
69
|
}
|
|
54
70
|
|