@deeeed/metamask-harness 0.72.0 → 0.74.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 +13 -0
- package/dist/adapters/core/surface.js +4 -1
- package/dist/adapters/extension/surface.js +9 -0
- package/dist/adapters/mobile/surface.js +9 -0
- package/dist/adapters/registry.js +17 -0
- package/dist/adapters/surface.js +6 -11
- package/dist/adapters/terminal/launch.js +85 -0
- package/dist/adapters/terminal/surface.js +4 -1
- package/dist/commands/launch/device.js +346 -0
- package/dist/commands/launch/extension.js +303 -3
- package/dist/commands/launch/index.js +9 -942
- package/dist/commands/launch/mobile.js +190 -2
- package/dist/commands/review.js +3 -1
- package/dist/commands/run-engine.js +3 -66
- package/dist/commands/shared.js +2 -2
- package/dist/discovery.js +15 -0
- package/dist/recipe-assistance-context.js +2 -1
- package/docs/CONTRIBUTING.md +10 -3
- package/docs/cli-contract.md +3 -3
- package/package.json +6 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.74.0 - 2026-10-03
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Adapters register in an `AdapterRegistry` (`src/adapters/registry.ts`) instead of a compiled-in union: the adapter id is a string, the four built-in surfaces register themselves, and `--adapter` resolves through the registry. `launch` moves behind a new `AdapterSurface.launch` member, so the command keeps only the shared grammar and no longer branches on the adapter name (39 such branches in `commands/launch/index.ts`, now 0). A test-only `example` adapter registers and launches with no shared-file edit. Flags, output, and the CLI goldens are unchanged.
|
|
10
|
+
|
|
11
|
+
## 0.73.0 - 2026-10-03
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- Recipe discovery runs on `@farmslot/recipe-cli`: `run --list` and recipe completion candidates use its readiness rule, and `run --describe` and recipe assistance use its call-graph composition. mm-harness still supplies the view and the policy: only the requested adapter's libraries are loaded, and readiness is judged against the MetaMask manifest `run` executes, so a broken recipe for another platform no longer matters and team manifests follow the same rules as `run --plan`. Flags and output are unchanged; the discovery goldens prove it.
|
|
16
|
+
- Move to the Farmslot release that ships `@farmslot/recipe-cli`: `@farmslot/protocol` 0.34.0, `@farmslot/recipe-harness` 0.22.1, `@farmslot/recipe-cli` 0.1.1, `@farmslot/agent-runtime` 0.16.0 and `@farmslot/expo-recipe` 0.13.1, all on one protocol and one recipe-harness copy. With recipe-harness 0.22, a library's `recipes/terminal/` folder counts as the terminal variant of its plain ids, so `run --list --adapter terminal` with the terminal library also lists `perps.manage-position` and its siblings; the `terminal.perps.*` ids are unchanged.
|
|
17
|
+
|
|
5
18
|
## 0.72.0 - 2026-10-03
|
|
6
19
|
|
|
7
20
|
### Added
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
3
3
|
import { shellQuote } from "../../commands/parse-args.js";
|
|
4
|
+
import { adapterRegistry } from "../registry.js";
|
|
4
5
|
const coreSurface = {
|
|
5
6
|
adapter: "core",
|
|
6
7
|
headless: true,
|
|
@@ -53,8 +54,10 @@ const coreSurface = {
|
|
|
53
54
|
launch: "mm-harness run <recipe> # run recipes against the headless core",
|
|
54
55
|
relaunch: "mm-harness verify",
|
|
55
56
|
runtimeProbeRecovery: (target) => `mm-harness verify --adapter core --target ${shellQuote(path.resolve(target))}`
|
|
56
|
-
}
|
|
57
|
+
},
|
|
58
|
+
launch: async ({ usage }) => usage("core is headless; there is nothing to launch.", "mm-harness verify")
|
|
57
59
|
};
|
|
60
|
+
adapterRegistry.register(coreSurface);
|
|
58
61
|
export {
|
|
59
62
|
coreSurface
|
|
60
63
|
};
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../slot-ports.js";
|
|
10
10
|
import { decideExtensionReadiness } from "./runtime-decision.js";
|
|
11
11
|
import { extensionProductConfigBlock } from "./product-config.js";
|
|
12
|
+
import { adapterRegistry } from "../registry.js";
|
|
12
13
|
function watcherStatus(buildLog) {
|
|
13
14
|
if (buildLog === "ok") return "up";
|
|
14
15
|
if (buildLog === "no-watch") return "down";
|
|
@@ -98,8 +99,16 @@ const extensionSurface = {
|
|
|
98
99
|
launch: "mm-harness launch",
|
|
99
100
|
relaunch: "mm-harness launch --build",
|
|
100
101
|
runtimeProbeRecovery: (target) => `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`
|
|
102
|
+
},
|
|
103
|
+
launch: async (context) => {
|
|
104
|
+
const [{ runDeviceLaunch }, { extensionLaunchProfile }] = await Promise.all([
|
|
105
|
+
import("../../commands/launch/device.js"),
|
|
106
|
+
import("../../commands/launch/extension.js")
|
|
107
|
+
]);
|
|
108
|
+
return runDeviceLaunch(context, extensionLaunchProfile);
|
|
101
109
|
}
|
|
102
110
|
};
|
|
111
|
+
adapterRegistry.register(extensionSurface);
|
|
103
112
|
export {
|
|
104
113
|
extensionRuntimeNextAction,
|
|
105
114
|
extensionSurface
|
|
@@ -5,6 +5,7 @@ import { shellQuote } from "../../commands/parse-args.js";
|
|
|
5
5
|
import { resolveMobileSlotPorts } from "../slot-ports.js";
|
|
6
6
|
import { mobileRuntimeStatus } from "./prepare.js";
|
|
7
7
|
import { hasRunwayProvisionBaseline, provisionRunwayMobile } from "./provision.js";
|
|
8
|
+
import { adapterRegistry } from "../registry.js";
|
|
8
9
|
function mobileRuntimeNextAction(reasonCode, target, platform) {
|
|
9
10
|
const resolved = shellQuote(path.resolve(target));
|
|
10
11
|
return reasonCode === "deps-missing" || reasonCode === "deps-partial" || reasonCode === "deps-stale" ? `cd ${resolved} && yarn setup` : `mm-harness launch ${platform} --adapter mobile --target ${resolved}`;
|
|
@@ -59,8 +60,16 @@ const mobileSurface = {
|
|
|
59
60
|
launch: "mm-harness launch ios",
|
|
60
61
|
relaunch: "mm-harness launch ios",
|
|
61
62
|
runtimeProbeRecovery: (target) => `mm-harness status --target ${shellQuote(path.resolve(target))} --json`
|
|
63
|
+
},
|
|
64
|
+
launch: async (context) => {
|
|
65
|
+
const [{ runDeviceLaunch }, { mobileLaunchProfile }] = await Promise.all([
|
|
66
|
+
import("../../commands/launch/device.js"),
|
|
67
|
+
import("../../commands/launch/mobile.js")
|
|
68
|
+
]);
|
|
69
|
+
return runDeviceLaunch(context, mobileLaunchProfile);
|
|
62
70
|
}
|
|
63
71
|
};
|
|
72
|
+
adapterRegistry.register(mobileSurface);
|
|
64
73
|
export {
|
|
65
74
|
mobileRuntimeNextAction,
|
|
66
75
|
mobileSurface
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const surfaces = /* @__PURE__ */ new Map();
|
|
2
|
+
const adapterRegistry = {
|
|
3
|
+
register(surface) {
|
|
4
|
+
if (surfaces.has(surface.adapter)) throw new Error(`adapter '${surface.adapter}' is already registered`);
|
|
5
|
+
surfaces.set(surface.adapter, surface);
|
|
6
|
+
},
|
|
7
|
+
get(id) {
|
|
8
|
+
const surface = surfaces.get(id);
|
|
9
|
+
if (!surface) throw new Error(`unknown adapter '${id}' (registered: ${[...surfaces.keys()].join(", ")})`);
|
|
10
|
+
return surface;
|
|
11
|
+
},
|
|
12
|
+
has: (id) => surfaces.has(id),
|
|
13
|
+
list: () => [...surfaces.keys()]
|
|
14
|
+
};
|
|
15
|
+
export {
|
|
16
|
+
adapterRegistry
|
|
17
|
+
};
|
package/dist/adapters/surface.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
mobile: mobileSurface,
|
|
7
|
-
extension: extensionSurface,
|
|
8
|
-
core: coreSurface,
|
|
9
|
-
terminal: terminalSurface
|
|
10
|
-
};
|
|
1
|
+
import { adapterRegistry } from "./registry.js";
|
|
2
|
+
import "./mobile/surface.js";
|
|
3
|
+
import "./extension/surface.js";
|
|
4
|
+
import "./core/surface.js";
|
|
5
|
+
import "./terminal/surface.js";
|
|
11
6
|
function getAdapterSurface(adapter) {
|
|
12
|
-
return
|
|
7
|
+
return adapterRegistry.get(adapter);
|
|
13
8
|
}
|
|
14
9
|
export {
|
|
15
10
|
getAdapterSurface
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { flag, str } from "../../commands/shared.js";
|
|
4
|
+
import { runnerDir } from "../../paths.js";
|
|
5
|
+
const TERMINAL_ONLY_LAUNCH_OPTIONS = Object.freeze({
|
|
6
|
+
signer: "--signer",
|
|
7
|
+
account: "--account",
|
|
8
|
+
freshProfile: "--fresh-profile",
|
|
9
|
+
headless: "--headless",
|
|
10
|
+
headful: "--headful",
|
|
11
|
+
slowMo: "--slow-mo",
|
|
12
|
+
network: "--network",
|
|
13
|
+
mainnetConfirmation: "--mainnet-confirmation"
|
|
14
|
+
});
|
|
15
|
+
function terminalOnlyLaunchFlags(options) {
|
|
16
|
+
return Object.entries(TERMINAL_ONLY_LAUNCH_OPTIONS).filter(([key]) => options[key] !== void 0 && options[key] !== false).map(([, name]) => name);
|
|
17
|
+
}
|
|
18
|
+
function parseTerminalLauncherOutput(output) {
|
|
19
|
+
const text = output.trim();
|
|
20
|
+
if (!text) return { status: "fail", error: "the terminal launcher printed no summary" };
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(text);
|
|
23
|
+
} catch {
|
|
24
|
+
const last = text.split("\n").filter((line) => line.trim()).pop() ?? "";
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(last);
|
|
27
|
+
} catch {
|
|
28
|
+
return { status: "fail", error: `the terminal launcher printed no JSON summary: ${last.slice(0, 200)}` };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function launchTerminal({ options, target, jsonOutput, stream, usage }) {
|
|
33
|
+
const cdpPort = str(options, "cdpPort") ?? process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
|
|
34
|
+
const appPort = str(options, "watcherPort") ?? process.env.TERMINAL_APP_PORT ?? process.env.RECIPE_WATCHER_PORT ?? process.env.WATCHER_PORT;
|
|
35
|
+
if (!cdpPort || !appPort) {
|
|
36
|
+
return usage(
|
|
37
|
+
"terminal launch needs the slot browser CDP port and the slot dev-server port.",
|
|
38
|
+
"mm-harness launch --adapter terminal --cdp-port <port> --watcher-port <app port>"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const args = [
|
|
42
|
+
path.join(runnerDir, "adapters", "terminal", "launch.mjs"),
|
|
43
|
+
"--target",
|
|
44
|
+
target,
|
|
45
|
+
"--cdp-port",
|
|
46
|
+
cdpPort,
|
|
47
|
+
"--app-port",
|
|
48
|
+
appPort,
|
|
49
|
+
"--signer",
|
|
50
|
+
str(options, "signer") ?? process.env.TERMINAL_SIGNER ?? "extension",
|
|
51
|
+
"--account",
|
|
52
|
+
str(options, "account") ?? process.env.TERMINAL_ACCOUNT ?? "dev1",
|
|
53
|
+
"--json"
|
|
54
|
+
];
|
|
55
|
+
if (flag(options, "freshProfile")) args.push("--fresh-profile");
|
|
56
|
+
if (flag(options, "headless")) args.push("--headless");
|
|
57
|
+
if (flag(options, "headful")) args.push("--headful");
|
|
58
|
+
const slowMo = str(options, "slowMo");
|
|
59
|
+
if (slowMo) args.push("--slow-mo", slowMo);
|
|
60
|
+
const network = str(options, "network");
|
|
61
|
+
if (network) args.push("--network", network);
|
|
62
|
+
const mainnetConfirmation = str(options, "mainnetConfirmation");
|
|
63
|
+
if (mainnetConfirmation) args.push("--mainnet-confirmation", mainnetConfirmation);
|
|
64
|
+
stream.phase("launch", { adapter: "terminal", cdpPort, appPort });
|
|
65
|
+
let output = "";
|
|
66
|
+
let status = 0;
|
|
67
|
+
try {
|
|
68
|
+
output = execFileSync(process.execPath, args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
status = 1;
|
|
71
|
+
output = String(error.stdout ?? "");
|
|
72
|
+
}
|
|
73
|
+
const result = parseTerminalLauncherOutput(output);
|
|
74
|
+
if (jsonOutput) console.log(JSON.stringify({ schemaVersion: 1, command: "launch", adapter: "terminal", target, ...result }, null, 2));
|
|
75
|
+
else if (result.status === "pass") console.log(`\u2713 terminal browser ${result.reused ? "reused" : "started"} on CDP ${result.cdpPort} (${result.signer}, ${result.account?.name})`);
|
|
76
|
+
else console.error(`\u2717 terminal launch failed: ${result.error ?? "unknown error"}`);
|
|
77
|
+
stream.complete(result.status === "pass" ? "pass" : "fail", status);
|
|
78
|
+
return result.status === "pass" ? 0 : 1;
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
TERMINAL_ONLY_LAUNCH_OPTIONS,
|
|
82
|
+
launchTerminal,
|
|
83
|
+
parseTerminalLauncherOutput,
|
|
84
|
+
terminalOnlyLaunchFlags
|
|
85
|
+
};
|
|
@@ -3,6 +3,7 @@ import { shellQuote } from "../../commands/parse-args.js";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { TERMINAL_CONSOLE_FILES, terminalRuntimeFile } from "./console-capture.js";
|
|
5
5
|
import { stopTerminalBrowserSync } from "./stop.js";
|
|
6
|
+
import { adapterRegistry } from "../registry.js";
|
|
6
7
|
function portFromEnv(...names) {
|
|
7
8
|
for (const name of names) {
|
|
8
9
|
const raw = process.env[name];
|
|
@@ -95,8 +96,10 @@ const terminalSurface = {
|
|
|
95
96
|
launch: "mm-harness launch --adapter terminal --cdp-port <port> --watcher-port <app port>",
|
|
96
97
|
relaunch: "mm-harness launch --adapter terminal --fresh-profile",
|
|
97
98
|
runtimeProbeRecovery: (target) => `mm-harness doctor --adapter terminal --target ${shellQuote(path.resolve(target))}`
|
|
98
|
-
}
|
|
99
|
+
},
|
|
100
|
+
launch: async (context) => (await import("./launch.js")).launchTerminal(context)
|
|
99
101
|
};
|
|
102
|
+
adapterRegistry.register(terminalSurface);
|
|
100
103
|
export {
|
|
101
104
|
terminalAppPort,
|
|
102
105
|
terminalCdpPort,
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { color, colorHumanMessage } from "../../cli-color.js";
|
|
4
|
+
import { handleHarness } from "../../harness.js";
|
|
5
|
+
import { recipeRuntimePath, walletFixturePath } from "../../paths.js";
|
|
6
|
+
import { getAdapterSurface } from "../../adapters/surface.js";
|
|
7
|
+
import { EXIT, flag, str } from "../shared.js";
|
|
8
|
+
import {
|
|
9
|
+
checkHealBounds,
|
|
10
|
+
ensureOverlay,
|
|
11
|
+
newHealState
|
|
12
|
+
} from "../../heal-bounds.js";
|
|
13
|
+
import {
|
|
14
|
+
FeatureFlagSpecError,
|
|
15
|
+
fixtureFeatureFlags,
|
|
16
|
+
parseRemoteFlagSpec,
|
|
17
|
+
remoteFlagRequestFor
|
|
18
|
+
} from "../../feature-flags.js";
|
|
19
|
+
import { terminalOnlyLaunchFlags } from "../../adapters/terminal/launch.js";
|
|
20
|
+
async function runDeviceLaunch(context, profile) {
|
|
21
|
+
const { adapter, options, target, heal, jsonOutput, machine, stream, usage } = context;
|
|
22
|
+
const terminalOnly = terminalOnlyLaunchFlags(options);
|
|
23
|
+
if (terminalOnly.length > 0) {
|
|
24
|
+
return usage(
|
|
25
|
+
`${terminalOnly.join(", ")} apply only to --adapter terminal; ${adapter} does not read them.`,
|
|
26
|
+
"drop the terminal flags, or pass --adapter terminal"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const wantBuild = flag(options, "build");
|
|
30
|
+
const wantVerify = flag(options, "verify");
|
|
31
|
+
const wantWatch = flag(options, "watch");
|
|
32
|
+
const wantRunway = flag(options, "runway");
|
|
33
|
+
const wantClearMetro = flag(options, "clearMetro");
|
|
34
|
+
const requestedSurface = str(options, "surface");
|
|
35
|
+
const legacySidepanel = flag(options, "sidepanel");
|
|
36
|
+
const legacyFullscreen = flag(options, "fullscreen");
|
|
37
|
+
const remoteFlagSpec = str(options, "remoteFlag");
|
|
38
|
+
const refused = profile.refuseFlags(context, { watch: wantWatch, runway: wantRunway, clearMetro: wantClearMetro, remoteFlagSpec });
|
|
39
|
+
if (refused) return usage(refused.message, refused.userAction);
|
|
40
|
+
if (requestedSurface !== void 0 && requestedSurface !== "fullscreen" && requestedSurface !== "sidepanel") {
|
|
41
|
+
return usage(
|
|
42
|
+
`--surface must be fullscreen or sidepanel (got: ${requestedSurface}).`,
|
|
43
|
+
"pass --surface fullscreen or --surface sidepanel"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (legacySidepanel && legacyFullscreen) {
|
|
47
|
+
return usage("--sidepanel and --fullscreen conflict.", "pass exactly one Extension surface");
|
|
48
|
+
}
|
|
49
|
+
const legacySurface = legacySidepanel ? "sidepanel" : legacyFullscreen ? "fullscreen" : void 0;
|
|
50
|
+
if (requestedSurface && legacySurface && requestedSurface !== legacySurface) {
|
|
51
|
+
return usage(
|
|
52
|
+
`--surface ${requestedSurface} conflicts with --${legacySurface}.`,
|
|
53
|
+
"pass one consistent Extension surface"
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const displayMode = requestedSurface ?? legacySurface ?? "fullscreen";
|
|
57
|
+
let remoteFlagRequest;
|
|
58
|
+
try {
|
|
59
|
+
remoteFlagRequest = remoteFlagRequestFor({
|
|
60
|
+
...fixtureFeatureFlags(walletFixturePath(target)),
|
|
61
|
+
...remoteFlagSpec === void 0 ? {} : parseRemoteFlagSpec(remoteFlagSpec)
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (!(error instanceof FeatureFlagSpecError)) throw error;
|
|
65
|
+
return usage(error.message, error.userAction);
|
|
66
|
+
}
|
|
67
|
+
for (const portFlag of ["cdpPort", "watcherPort"]) {
|
|
68
|
+
const value = str(options, portFlag);
|
|
69
|
+
if (value !== void 0 && !/^\d+$/u.test(value)) {
|
|
70
|
+
return usage(
|
|
71
|
+
`--${portFlag === "cdpPort" ? "cdp-port" : "watcher-port"} must be numeric (got: ${value}).`,
|
|
72
|
+
`pass a numeric port, e.g. --${portFlag === "cdpPort" ? "cdp-port 6663" : "watcher-port 8081"}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
getAdapterSurface(adapter).resolveSlotPorts(target);
|
|
77
|
+
const envError = profile.applyEnv?.(context);
|
|
78
|
+
if (envError) return usage(envError.message, envError.userAction);
|
|
79
|
+
const cdpPort = str(options, "cdpPort");
|
|
80
|
+
if (cdpPort) {
|
|
81
|
+
process.env.CDP_PORT = cdpPort;
|
|
82
|
+
process.env.RECIPE_CDP_PORT = cdpPort;
|
|
83
|
+
}
|
|
84
|
+
const watcherPort = str(options, "watcherPort");
|
|
85
|
+
if (watcherPort) {
|
|
86
|
+
process.env.WATCHER_PORT = watcherPort;
|
|
87
|
+
process.env.RECIPE_WATCHER_PORT = watcherPort;
|
|
88
|
+
process.env.METRO_PORT = watcherPort;
|
|
89
|
+
}
|
|
90
|
+
const launch = {
|
|
91
|
+
...context,
|
|
92
|
+
tier: wantBuild ? "build" : "quick",
|
|
93
|
+
wantWatch,
|
|
94
|
+
wantVerify,
|
|
95
|
+
wantClearMetro,
|
|
96
|
+
displayMode,
|
|
97
|
+
remoteFlagRequest,
|
|
98
|
+
compose: (tier2, restartApp = false, clearMetro = false) => profile.compose(launch, tier2, restartApp, clearMetro),
|
|
99
|
+
settle: (state2, provenFlags, sidepanelRecoveryAllowed = true) => settleLaunch(launch, profile, state2, sidepanelRecoveryAllowed, provenFlags),
|
|
100
|
+
fail: (state2, failure) => launchFail(launch, profile, state2, failure)
|
|
101
|
+
};
|
|
102
|
+
const { tier } = launch;
|
|
103
|
+
profile.start(launch);
|
|
104
|
+
const state = newHealState();
|
|
105
|
+
if (tier === "quick" && nativeInputsChanged(target, profile.nativeInputs)) {
|
|
106
|
+
return usage("native build inputs changed since last build.", profile.rebuildCommand(launch));
|
|
107
|
+
}
|
|
108
|
+
stream.phase("install");
|
|
109
|
+
const ensured = await ensureOverlay(adapter, target, heal, state, machine);
|
|
110
|
+
if (!ensured.ok) {
|
|
111
|
+
return launch.fail(state, {
|
|
112
|
+
code: "OVERLAY_INSTALL_FAILED",
|
|
113
|
+
message: ensured.error ?? "overlay install failed",
|
|
114
|
+
recoverable: false,
|
|
115
|
+
exitCode: EXIT.infra
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
stream.phase("healthcheck");
|
|
119
|
+
const blocked = await profile.healthcheck?.(launch, state);
|
|
120
|
+
if (blocked !== void 0) return blocked;
|
|
121
|
+
stream.phase("launch");
|
|
122
|
+
let attempt = await launch.compose(tier, false, wantClearMetro);
|
|
123
|
+
if (attempt.status === 0) {
|
|
124
|
+
return await launch.settle(state, attempt.remoteFeatureFlags);
|
|
125
|
+
}
|
|
126
|
+
const readinessFailure = readinessBlockFailure(attempt);
|
|
127
|
+
if (readinessFailure) return launch.fail(state, readinessFailure);
|
|
128
|
+
const classified = profile.classify(launch, attempt.output);
|
|
129
|
+
if (classified) return launch.fail(state, classified);
|
|
130
|
+
if (heal === "off") {
|
|
131
|
+
return launch.fail(state, {
|
|
132
|
+
code: "LAUNCH_FAILED",
|
|
133
|
+
message: `launch ${adapter}${launch.mobileTarget ? ` ${launch.mobileTarget}` : ""} failed (healing off)`,
|
|
134
|
+
recoverable: false,
|
|
135
|
+
exitCode: EXIT.infra
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const handled = await profile.beforeHeal?.(launch, attempt, state);
|
|
139
|
+
if (handled !== void 0) return handled;
|
|
140
|
+
const bound = checkHealBounds(target, attempt.output, state);
|
|
141
|
+
if (bound !== null) {
|
|
142
|
+
return launch.fail(state, {
|
|
143
|
+
code: bound.code,
|
|
144
|
+
message: bound.message,
|
|
145
|
+
recoverable: false,
|
|
146
|
+
userAction: bound.userAction,
|
|
147
|
+
exitCode: bound.exitCode,
|
|
148
|
+
originalError: bound.originalError
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const recovery = profile.recovery(launch, attempt.output);
|
|
152
|
+
state.attemptedRecoveries.push(recovery.code);
|
|
153
|
+
stream.phase("recover");
|
|
154
|
+
const recoveryTier = recovery.restartApp && tier === "build" ? "quick" : tier;
|
|
155
|
+
if (!jsonOutput) {
|
|
156
|
+
process.stderr.write(`launch: recovering (${recovery.code}) with the ${recoveryTier} tier
|
|
157
|
+
`);
|
|
158
|
+
}
|
|
159
|
+
attempt = await launch.compose(recoveryTier, recovery.restartApp, false);
|
|
160
|
+
if (attempt.status === 0) {
|
|
161
|
+
state.recovered.push(recovery.code);
|
|
162
|
+
return await launch.settle(state, attempt.remoteFeatureFlags);
|
|
163
|
+
}
|
|
164
|
+
const retryReadinessFailure = readinessBlockFailure(attempt);
|
|
165
|
+
if (retryReadinessFailure) return launch.fail(state, retryReadinessFailure);
|
|
166
|
+
return launch.fail(state, {
|
|
167
|
+
code: "SAME_RECOVERY_TWICE",
|
|
168
|
+
message: `launch ${adapter}${launch.mobileTarget ? ` ${launch.mobileTarget}` : ""} failed again after ${recovery.code} recovery \u2014 refusing to loop.`,
|
|
169
|
+
recoverable: false,
|
|
170
|
+
exitCode: EXIT.bounded,
|
|
171
|
+
originalError: attempt.output.trim() || void 0
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function readinessBlockFailure(result) {
|
|
175
|
+
const block = result.readinessBlock;
|
|
176
|
+
if (!block) return void 0;
|
|
177
|
+
return {
|
|
178
|
+
code: block.reasonCode === "deps-not-ready" ? "MOBILE_DEPENDENCIES_NOT_READY" : "MOBILE_READINESS_BLOCKED",
|
|
179
|
+
message: block.message,
|
|
180
|
+
userAction: block.userAction,
|
|
181
|
+
recoverable: false,
|
|
182
|
+
exitCode: result.status
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function shellQuote(value) {
|
|
186
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) return value;
|
|
187
|
+
return `'${value.replace(/'/gu, `'"'"'`)}'`;
|
|
188
|
+
}
|
|
189
|
+
function nativeInputsChanged(target, paths) {
|
|
190
|
+
const baselineFile = recipeRuntimePath(target, ".last-build-ref");
|
|
191
|
+
let baseline;
|
|
192
|
+
try {
|
|
193
|
+
baseline = fs.readFileSync(baselineFile, "utf8").trim();
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
if (!baseline) return false;
|
|
198
|
+
try {
|
|
199
|
+
const diff = execFileSync("git", ["-C", target, "diff", "--name-only", baseline, "--", ...paths], {
|
|
200
|
+
encoding: "utf8",
|
|
201
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
202
|
+
}).trim();
|
|
203
|
+
return diff.length > 0;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function settleLaunch(launch, profile, state, sidepanelRecoveryAllowed, provenFlags) {
|
|
209
|
+
const proven = profile.provenFlags(launch, launch.remoteFlagRequest?.flags ?? {}, provenFlags);
|
|
210
|
+
if (!("flags" in proven)) return launch.fail(state, proven);
|
|
211
|
+
const stopped = await profile.finish?.(launch, state, sidepanelRecoveryAllowed);
|
|
212
|
+
if (stopped !== void 0) return stopped;
|
|
213
|
+
const { adapter, target, stream, tier } = launch;
|
|
214
|
+
if (launch.wantVerify) {
|
|
215
|
+
stream.phase("verify");
|
|
216
|
+
const verifyArgs = ["verify", "--adapter", adapter, "--target", target, ...profile.verifyArgs(launch)];
|
|
217
|
+
if (launch.machine) verifyArgs.push("--json");
|
|
218
|
+
const exitCode = await handleHarness(verifyArgs);
|
|
219
|
+
if (stream.enabled) {
|
|
220
|
+
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
221
|
+
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
222
|
+
stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode, {
|
|
223
|
+
adapter,
|
|
224
|
+
platform: profile.platformLabel(launch),
|
|
225
|
+
tier,
|
|
226
|
+
recovered: state.recovered,
|
|
227
|
+
mutations: state.mutations,
|
|
228
|
+
metro: profile.evidence?.(target),
|
|
229
|
+
remoteFeatureFlags: proven.flags
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return exitCode;
|
|
233
|
+
}
|
|
234
|
+
return launchPass(launch, profile, state, proven.flags);
|
|
235
|
+
}
|
|
236
|
+
function launchPass(launch, profile, state, remoteFlags) {
|
|
237
|
+
const { adapter, mobileTarget, target, stream, tier } = launch;
|
|
238
|
+
if (stream.enabled) {
|
|
239
|
+
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
240
|
+
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
241
|
+
stream.complete("pass", EXIT.ok, {
|
|
242
|
+
adapter,
|
|
243
|
+
platform: profile.platformLabel(launch),
|
|
244
|
+
tier,
|
|
245
|
+
recovered: state.recovered,
|
|
246
|
+
mutations: state.mutations,
|
|
247
|
+
metro: profile.evidence?.(target),
|
|
248
|
+
remoteFeatureFlags: remoteFlags
|
|
249
|
+
});
|
|
250
|
+
} else if (launch.jsonOutput) {
|
|
251
|
+
console.log(
|
|
252
|
+
JSON.stringify(
|
|
253
|
+
{
|
|
254
|
+
schemaVersion: 1,
|
|
255
|
+
command: "launch",
|
|
256
|
+
adapter,
|
|
257
|
+
platform: profile.platformLabel(launch),
|
|
258
|
+
tier,
|
|
259
|
+
status: "pass",
|
|
260
|
+
phase: "launch",
|
|
261
|
+
recovered: state.recovered,
|
|
262
|
+
mutations: state.mutations,
|
|
263
|
+
metro: profile.evidence?.(target),
|
|
264
|
+
remoteFeatureFlags: remoteFlags,
|
|
265
|
+
exitCode: EXIT.ok
|
|
266
|
+
},
|
|
267
|
+
null,
|
|
268
|
+
2
|
|
269
|
+
)
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
const tierNote = tier === "quick" ? "quick relaunch, no native build" : tier;
|
|
273
|
+
const devNote = process.env.MM_HARNESS_BIN ? ` ${color("dim", "[dev: MM_HARNESS_BIN]")}` : "";
|
|
274
|
+
console.error(
|
|
275
|
+
`${color("ok", "\u2713")} launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} \u2014 ${color("ok", profile.deviceLabel(launch))} \xB7 ${tierNote} \xB7 app + bridge ready${devNote}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return EXIT.ok;
|
|
279
|
+
}
|
|
280
|
+
function launchFail(launch, profile, state, failure) {
|
|
281
|
+
const { adapter, mobileTarget, target, stream, tier } = launch;
|
|
282
|
+
const userAction = failure.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
|
|
283
|
+
if (stream.enabled) {
|
|
284
|
+
for (const mutation of state.mutations) stream.mutation(mutation);
|
|
285
|
+
for (const recovery of state.recovered) stream.recovery(recovery);
|
|
286
|
+
stream.error({
|
|
287
|
+
code: failure.code,
|
|
288
|
+
message: failure.message,
|
|
289
|
+
retryable: failure.recoverable,
|
|
290
|
+
userAction,
|
|
291
|
+
originalError: failure.originalError ?? null
|
|
292
|
+
});
|
|
293
|
+
stream.complete("fail", failure.exitCode, {
|
|
294
|
+
adapter,
|
|
295
|
+
platform: mobileTarget ?? null,
|
|
296
|
+
tier,
|
|
297
|
+
recovered: state.recovered,
|
|
298
|
+
mutations: state.mutations,
|
|
299
|
+
recoverable: failure.recoverable,
|
|
300
|
+
attemptedRecoveries: state.attemptedRecoveries,
|
|
301
|
+
metro: profile.evidence?.(target)
|
|
302
|
+
});
|
|
303
|
+
} else if (launch.jsonOutput) {
|
|
304
|
+
console.log(
|
|
305
|
+
JSON.stringify(
|
|
306
|
+
{
|
|
307
|
+
schemaVersion: 1,
|
|
308
|
+
command: "launch",
|
|
309
|
+
adapter,
|
|
310
|
+
platform: mobileTarget ?? null,
|
|
311
|
+
tier,
|
|
312
|
+
status: "fail",
|
|
313
|
+
phase: state.attemptedRecoveries.length ? "recover" : "launch",
|
|
314
|
+
recovered: state.recovered,
|
|
315
|
+
mutations: state.mutations,
|
|
316
|
+
recoverable: failure.recoverable,
|
|
317
|
+
attemptedRecoveries: state.attemptedRecoveries,
|
|
318
|
+
metro: profile.evidence?.(target),
|
|
319
|
+
exitCode: failure.exitCode,
|
|
320
|
+
error: {
|
|
321
|
+
code: failure.code,
|
|
322
|
+
message: failure.message,
|
|
323
|
+
retryable: failure.recoverable,
|
|
324
|
+
userAction,
|
|
325
|
+
originalError: failure.originalError ?? null
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
null,
|
|
329
|
+
2
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
} else {
|
|
333
|
+
console.error(colorHumanMessage(
|
|
334
|
+
`\u2717 launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed
|
|
335
|
+
${failure.message}` + (failure.originalError ? `
|
|
336
|
+
--- original failure ---
|
|
337
|
+
${failure.originalError}` : "") + `
|
|
338
|
+
Next: ${userAction}`
|
|
339
|
+
));
|
|
340
|
+
}
|
|
341
|
+
return failure.exitCode;
|
|
342
|
+
}
|
|
343
|
+
export {
|
|
344
|
+
runDeviceLaunch,
|
|
345
|
+
shellQuote
|
|
346
|
+
};
|