@deeeed/metamask-harness 0.17.4 → 0.18.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 +775 -0
- package/README.md +65 -71
- package/adapters/extension/ensure-browser.sh +12 -1
- package/adapters/extension/inject.mjs +7 -0
- package/adapters/extension/launch-browser.cjs +10 -3
- package/adapters/extension/lib/chrome-args.cjs +31 -0
- package/adapters/extension/lib/macos-focus.cjs +32 -0
- package/adapters/extension/live.sh +10 -20
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/open-device.sh +45 -7
- package/adapters/mobile/verify.sh +15 -3
- package/adapters/shared/harness-source-fingerprint.mjs +49 -0
- package/adapters/shared/install-repo-deps.sh +1 -5
- package/adapters/shared/open-debug.mjs +184 -103
- package/adapters/shared/resolve-slot-ports-core.mjs +23 -6
- package/adapters/shared/resolve-slot-ports.sh +22 -5
- package/bin/mm-harness +15 -3
- package/dist/adapters/core/surface.js +4 -1
- package/dist/adapters/extension/console-capture.js +3 -1
- package/dist/adapters/extension/harness-freshness.js +39 -0
- package/dist/adapters/extension/product-config.js +110 -0
- package/dist/adapters/extension/runtime-decision.js +20 -71
- package/dist/adapters/extension/surface.js +19 -1
- package/dist/adapters/mobile/prepare.js +17 -0
- package/dist/adapters/mobile/source-freshness.js +26 -41
- package/dist/adapters/mobile/surface.js +4 -1
- package/dist/adapters/resolve-slot-ports.js +2 -0
- package/dist/adapters/slot-ports.js +13 -32
- package/dist/adapters.js +50 -17
- package/dist/checkout-lock.js +27 -2
- package/dist/cli-color.js +19 -0
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -3
- package/dist/command-contract.js +13 -3
- package/dist/commands/call.js +115 -29
- package/dist/commands/checklist.js +4 -1
- package/dist/commands/completion-candidates.js +20 -13
- package/dist/commands/debug.js +31 -38
- package/dist/commands/doctor.js +33 -6
- package/dist/commands/fixtures.js +65 -17
- package/dist/commands/flows.js +39 -10
- package/dist/commands/launch/extension.js +40 -15
- package/dist/commands/launch/index.js +41 -5
- package/dist/commands/list-executables.js +151 -29
- package/dist/commands/manifest.js +127 -18
- package/dist/commands/parse-args.js +11 -1
- package/dist/commands/run-engine.js +384 -56
- package/dist/commands/run.js +112 -17
- package/dist/commands/shared.js +22 -2
- package/dist/commands/status-probe.js +3 -0
- package/dist/commands/status.js +1 -0
- package/dist/completions-cache.js +1 -1
- package/dist/doctor.js +56 -6
- package/dist/harness.js +6 -5
- package/dist/heal-bounds.js +1 -1
- package/dist/live-adapter-contract.js +132 -12
- package/dist/manifest.js +161 -1
- package/dist/mm-harness-cli.js +13 -7
- package/dist/recipe-security.js +178 -0
- package/dist/runner.js +87 -13
- package/dist/runtime-context.js +8 -26
- package/docs/CONTRIBUTING.md +137 -0
- package/docs/QA.md +185 -0
- package/docs/RECIPES.md +161 -0
- package/docs/SECURITY.md +88 -0
- package/library/actions/core/perps/read_account.mjs +2 -2
- package/library/actions/core/perps/read_orders.mjs +2 -1
- package/library/actions/core/perps/read_positions.mjs +2 -1
- package/library/actions/core/wallet/list_accounts.mjs +95 -0
- package/library/actions/extension/platform/cdp.mjs +1 -0
- package/library/actions/extension/wallet/list_accounts.mjs +41 -0
- package/library/actions/mobile/platform/bridge.mjs +1 -5
- package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
- package/library/manifests/core.action-manifest.json +61 -0
- package/library/manifests/extension.action-manifest.json +53 -0
- package/library/manifests/mobile.action-manifest.json +53 -0
- package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
- package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
- package/package.json +10 -6
- package/scripts/completions.sh +7 -7
- package/docs/ADAPTER-SURFACE.md +0 -26
- package/docs/CHEATSHEET.md +0 -48
- package/docs/CLI-ERGONOMICS-AUDIT.md +0 -32
- package/docs/CLI-ERGONOMICS-HUMAN-QA.md +0 -64
- package/docs/CODE-MAP.md +0 -62
- package/docs/UX-PRINCIPLES.md +0 -66
- package/docs/VIDEO-DEMO-VALIDATION.md +0 -74
- package/docs/architecture.md +0 -88
- package/docs/live-adapter-contract.md +0 -190
- package/docs/package-boundaries.md +0 -47
- package/docs/perps-flow-catalog.md +0 -235
- package/docs/recipe-libraries.md +0 -71
- package/docs/runtime-file-conventions.md +0 -36
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { shellQuote } from "../../commands/parse-args.js";
|
|
4
|
+
function isConfiguredInfuraId(value, placeholder) {
|
|
5
|
+
return value.length > 0 && value !== placeholder && value !== "true" && value !== "false" && value !== "null";
|
|
6
|
+
}
|
|
7
|
+
function extensionProductConfigBlock(target) {
|
|
8
|
+
const template = path.join(target, ".metamaskrc.dist");
|
|
9
|
+
const templateSnapshot = readConfigSnapshot(template);
|
|
10
|
+
if (templateSnapshot.kind === "missing") return null;
|
|
11
|
+
if (templateSnapshot.kind === "invalid") {
|
|
12
|
+
return invalidConfigBlock(target, ".metamaskrc.dist", templateSnapshot.reason);
|
|
13
|
+
}
|
|
14
|
+
const placeholder = readInfuraProjectId(templateSnapshot.text) ?? "";
|
|
15
|
+
const configuredFromEnvironment = process.env.INFURA_PROJECT_ID;
|
|
16
|
+
if (configuredFromEnvironment !== void 0) {
|
|
17
|
+
if (isConfiguredInfuraId(configuredFromEnvironment, placeholder)) return null;
|
|
18
|
+
return {
|
|
19
|
+
message: "Extension product configuration is blocked by an unusable exported INFURA_PROJECT_ID (the value is never displayed).",
|
|
20
|
+
userAction: `unset INFURA_PROJECT_ID; cd ${shellQuote(path.resolve(target))} && cp -n .metamaskrc.dist .metamaskrc && \${EDITOR:-vi} .metamaskrc`
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const productionConfig = readConfigSnapshot(path.join(target, ".metamaskprodrc"));
|
|
24
|
+
if (productionConfig.kind === "invalid") {
|
|
25
|
+
return invalidConfigBlock(target, ".metamaskprodrc", productionConfig.reason);
|
|
26
|
+
}
|
|
27
|
+
if (productionConfig.kind === "file") {
|
|
28
|
+
const value2 = readInfuraProjectId(productionConfig.text);
|
|
29
|
+
if (value2 !== void 0) {
|
|
30
|
+
if (isConfiguredInfuraId(value2, placeholder)) return null;
|
|
31
|
+
return {
|
|
32
|
+
message: "Extension product configuration is incomplete: .metamaskprodrc shadows .metamaskrc but does not contain a usable INFURA_PROJECT_ID (the value is never displayed).",
|
|
33
|
+
userAction: `cd ${shellQuote(path.resolve(target))} && \${EDITOR:-vi} .metamaskprodrc`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const config = path.join(target, ".metamaskrc");
|
|
38
|
+
const configSnapshot = readConfigSnapshot(config);
|
|
39
|
+
if (configSnapshot.kind === "missing") {
|
|
40
|
+
return {
|
|
41
|
+
message: "Extension product configuration is required before launch: create .metamaskrc and set a non-placeholder INFURA_PROJECT_ID (the value is never displayed).",
|
|
42
|
+
userAction: `cd ${shellQuote(path.resolve(target))} && cp .metamaskrc.dist .metamaskrc && \${EDITOR:-vi} .metamaskrc`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (configSnapshot.kind === "invalid") {
|
|
46
|
+
return invalidConfigBlock(target, ".metamaskrc", configSnapshot.reason);
|
|
47
|
+
}
|
|
48
|
+
const value = readInfuraProjectId(configSnapshot.text) ?? "";
|
|
49
|
+
if (isConfiguredInfuraId(value, placeholder)) return null;
|
|
50
|
+
return {
|
|
51
|
+
message: "Extension product configuration is incomplete: .metamaskrc must contain a non-placeholder INFURA_PROJECT_ID (the value is never displayed).",
|
|
52
|
+
userAction: `cd ${shellQuote(path.resolve(target))} && \${EDITOR:-vi} .metamaskrc`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function readConfigSnapshot(file) {
|
|
56
|
+
let descriptor;
|
|
57
|
+
try {
|
|
58
|
+
descriptor = fs.openSync(
|
|
59
|
+
file,
|
|
60
|
+
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
|
|
61
|
+
);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const code = error.code;
|
|
64
|
+
if (code === "ENOENT") return { kind: "missing" };
|
|
65
|
+
if (code === "ELOOP") return { kind: "invalid", reason: "symlink" };
|
|
66
|
+
return { kind: "invalid", reason: "unreadable" };
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
if (!fs.fstatSync(descriptor).isFile()) return { kind: "invalid", reason: "not-regular" };
|
|
70
|
+
return { kind: "file", text: fs.readFileSync(descriptor, "utf8") };
|
|
71
|
+
} finally {
|
|
72
|
+
fs.closeSync(descriptor);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function invalidConfigBlock(target, name, reason) {
|
|
76
|
+
const resolved = shellQuote(path.resolve(target));
|
|
77
|
+
if (name === ".metamaskrc.dist") {
|
|
78
|
+
return {
|
|
79
|
+
message: `Extension product configuration is invalid: ${name} is ${reason === "symlink" ? "a symbolic link" : reason === "not-regular" ? "not a regular file" : "unreadable"}.`,
|
|
80
|
+
userAction: `cd ${resolved} && ls -l ${name}`
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (reason === "unreadable") {
|
|
84
|
+
return {
|
|
85
|
+
message: `Extension product configuration could not be read from ${name}.`,
|
|
86
|
+
userAction: `cd ${resolved} && ls -l ${name} && \${EDITOR:-vi} ${name}`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
message: reason === "symlink" ? `Extension product configuration is invalid: ${name} must not be a symbolic link.` : `Extension product configuration is invalid: ${name} must be a regular file, not its current file type.`,
|
|
91
|
+
userAction: `cd ${resolved} && rm ${name} && ${name === ".metamaskrc" ? "cp .metamaskrc.dist .metamaskrc && " : ""}\${EDITOR:-vi} ${name}`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function readInfuraProjectId(text) {
|
|
95
|
+
const lines = text.replace(/\r\n?/gu, "\n");
|
|
96
|
+
const pattern = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gmu;
|
|
97
|
+
let result;
|
|
98
|
+
for (const match of lines.matchAll(pattern)) {
|
|
99
|
+
if (match[1] !== "INFURA_PROJECT_ID") continue;
|
|
100
|
+
let value = (match[2] ?? "").trim();
|
|
101
|
+
const quote = value[0];
|
|
102
|
+
value = value.replace(/^(['"`])([\s\S]*)\1$/u, "$2");
|
|
103
|
+
if (quote === '"') value = value.replace(/\\n/gu, "\n").replace(/\\r/gu, "\r");
|
|
104
|
+
result = value;
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
export {
|
|
109
|
+
extensionProductConfigBlock
|
|
110
|
+
};
|
|
@@ -163,81 +163,37 @@ function runtimeDistCheck(target) {
|
|
|
163
163
|
let output;
|
|
164
164
|
try {
|
|
165
165
|
output = execFileSync("rsync", [
|
|
166
|
-
"-
|
|
166
|
+
"-rnic",
|
|
167
167
|
"--exclude",
|
|
168
168
|
"_metadata",
|
|
169
|
+
"--exclude",
|
|
170
|
+
"home.html",
|
|
171
|
+
"--exclude",
|
|
172
|
+
"sidepanel.html",
|
|
169
173
|
"--out-format=%n",
|
|
170
174
|
`${dist}${path.sep}`,
|
|
171
175
|
`${runtimeDist}${path.sep}`
|
|
172
176
|
], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
173
|
-
} catch (error) {
|
|
174
|
-
if (error.code === "ENOENT") {
|
|
175
|
-
try {
|
|
176
|
-
const modified2 = filesystemSnapshotDiff(dist, runtimeDist).filter((relative) => !slotTitleOnlyDifference(dist, runtimeDist, relative)).slice(0, 10);
|
|
177
|
-
return modified2.length > 0 ? { status: "stale", comparison: "filesystem-quick", modified: modified2 } : { status: "fresh", comparison: "filesystem-quick" };
|
|
178
|
-
} catch (fallbackError) {
|
|
179
|
-
return {
|
|
180
|
-
status: "unknown",
|
|
181
|
-
error: `snapshot comparison failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
const status = error.status;
|
|
186
|
-
return {
|
|
187
|
-
status: "unknown",
|
|
188
|
-
error: `rsync snapshot comparison failed${typeof status === "number" ? ` with exit ${status}` : ""}`
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
const modified = output.split("\n").map((line) => line.trim()).filter(Boolean).filter((relative) => !slotTitleOnlyDifference(dist, runtimeDist, relative)).slice(0, 10);
|
|
192
|
-
return modified.length > 0 ? { status: "stale", comparison: "rsync-quick", modified } : { status: "fresh", comparison: "rsync-quick" };
|
|
193
|
-
}
|
|
194
|
-
function slotTitleOnlyDifference(dist, runtimeDist, relative) {
|
|
195
|
-
if (relative !== "home.html" && relative !== "sidepanel.html") return false;
|
|
196
|
-
try {
|
|
197
|
-
const normalize = (source) => source.replace(
|
|
198
|
-
/<title>([^<]*)<\/title>/iu,
|
|
199
|
-
(_match, title) => `<title>${String(title || "MetaMask").replace(/^.+?\s+[—–-]\s+/u, "") || "MetaMask"}</title>`
|
|
200
|
-
);
|
|
201
|
-
return normalize(fs.readFileSync(path.join(dist, relative), "utf8")) === normalize(fs.readFileSync(path.join(runtimeDist, relative), "utf8"));
|
|
202
177
|
} catch {
|
|
203
|
-
return
|
|
178
|
+
return { status: "stale", modified: ["<snapshot comparison failed>"] };
|
|
204
179
|
}
|
|
180
|
+
const modified = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
181
|
+
for (const name of ["home.html", "sidepanel.html"]) {
|
|
182
|
+
const source = path.join(dist, name);
|
|
183
|
+
const loaded = path.join(runtimeDist, name);
|
|
184
|
+
if (normalizedRuntimeHtml(source) !== normalizedRuntimeHtml(loaded)) modified.push(name);
|
|
185
|
+
}
|
|
186
|
+
const boundedModified = [...new Set(modified)].slice(0, 10);
|
|
187
|
+
return boundedModified.length > 0 ? { status: "stale", modified: boundedModified } : { status: "fresh" };
|
|
205
188
|
}
|
|
206
|
-
function
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const right = snapshotEntriesByPath.get(relative);
|
|
213
|
-
return left === void 0 || right === void 0 || JSON.stringify(left) !== JSON.stringify(right);
|
|
189
|
+
function normalizedRuntimeHtml(file) {
|
|
190
|
+
if (!fs.existsSync(file)) return null;
|
|
191
|
+
const source = fs.readFileSync(file, "utf8");
|
|
192
|
+
return source.replace(/<title>([^<]*)<\/title>/iu, (_match, title) => {
|
|
193
|
+
const base = String(title || "MetaMask").replace(/^.+?\s+[—–-]\s+/u, "") || "MetaMask";
|
|
194
|
+
return `<title>${base}</title>`;
|
|
214
195
|
});
|
|
215
196
|
}
|
|
216
|
-
function snapshotEntries(root) {
|
|
217
|
-
const entries = /* @__PURE__ */ new Map();
|
|
218
|
-
const walk2 = (directory) => {
|
|
219
|
-
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
220
|
-
if (entry.name === "_metadata") continue;
|
|
221
|
-
const absolute = path.join(directory, entry.name);
|
|
222
|
-
const relative = path.relative(root, absolute);
|
|
223
|
-
const stat = fs.lstatSync(absolute);
|
|
224
|
-
if (entry.isDirectory()) {
|
|
225
|
-
entries.set(relative, { type: "directory" });
|
|
226
|
-
walk2(absolute);
|
|
227
|
-
} else if (entry.isSymbolicLink()) {
|
|
228
|
-
entries.set(relative, { type: "symlink", link: fs.readlinkSync(absolute) });
|
|
229
|
-
} else if (entry.isFile()) {
|
|
230
|
-
entries.set(relative, {
|
|
231
|
-
type: "file",
|
|
232
|
-
size: stat.size,
|
|
233
|
-
mtimeSeconds: Math.floor(stat.mtimeMs / 1e3)
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
walk2(root);
|
|
239
|
-
return entries;
|
|
240
|
-
}
|
|
241
197
|
function distCheck(target) {
|
|
242
198
|
const manifestPath = path.join(target, "dist/chrome/manifest.json");
|
|
243
199
|
if (!fs.existsSync(manifestPath)) return { status: "no-build" };
|
|
@@ -346,13 +302,6 @@ async function decideExtensionReadiness(target, options = {}) {
|
|
|
346
302
|
reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
|
|
347
303
|
actions: buildAction(resolved, false)
|
|
348
304
|
},
|
|
349
|
-
{
|
|
350
|
-
when: runtimeDist.status === "unknown",
|
|
351
|
-
decision: "blocked",
|
|
352
|
-
reasonCode: "runtime-dist-check-failed",
|
|
353
|
-
reasons: [runtimeDist.error ?? "The loaded runtime snapshot could not be compared with dist/chrome."],
|
|
354
|
-
actions: []
|
|
355
|
-
},
|
|
356
305
|
{
|
|
357
306
|
when: runtimeDist.status !== "fresh",
|
|
358
307
|
decision: "relaunch",
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { recipeRuntimePath, runnerDir } from "../../paths.js";
|
|
4
|
+
import { shellQuote } from "../../commands/parse-args.js";
|
|
4
5
|
import {
|
|
5
6
|
isExtensionWatcherLive,
|
|
6
7
|
resolveExtensionSlotPorts,
|
|
7
8
|
stopExtensionRuntime
|
|
8
9
|
} from "../slot-ports.js";
|
|
9
10
|
import { decideExtensionReadiness } from "./runtime-decision.js";
|
|
11
|
+
import { extensionProductConfigBlock } from "./product-config.js";
|
|
10
12
|
function watcherStatus(buildLog) {
|
|
11
13
|
if (buildLog === "ok") return "up";
|
|
12
14
|
if (buildLog === "no-watch") return "down";
|
|
@@ -22,10 +24,25 @@ const extensionSurface = {
|
|
|
22
24
|
const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : void 0;
|
|
23
25
|
const report = await decideExtensionReadiness(target, { cdpPort, pageMode: "home-or-sidepanel" });
|
|
24
26
|
const watcherLive = isExtensionWatcherLive(target);
|
|
27
|
+
const productConfigBlock = report.decision === "ready" ? null : extensionProductConfigBlock(target);
|
|
28
|
+
if (productConfigBlock) {
|
|
29
|
+
return {
|
|
30
|
+
decision: "blocked",
|
|
31
|
+
reasonCode: "product-config-required",
|
|
32
|
+
reasons: [productConfigBlock.message],
|
|
33
|
+
nextAction: productConfigBlock.userAction,
|
|
34
|
+
deps: report.checks.deps.status,
|
|
35
|
+
devServer: {
|
|
36
|
+
label: "webpack",
|
|
37
|
+
status: watcherLive ? watcherStatus(report.checks.buildLog.status) : "down"
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
25
41
|
return {
|
|
26
42
|
decision: report.decision,
|
|
27
43
|
reasonCode: report.reasonCode,
|
|
28
44
|
reasons: report.reasons,
|
|
45
|
+
nextAction: report.decision === "ready" ? void 0 : `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`,
|
|
29
46
|
deps: report.checks.deps.status,
|
|
30
47
|
devServer: {
|
|
31
48
|
label: "webpack",
|
|
@@ -74,7 +91,8 @@ const extensionSurface = {
|
|
|
74
91
|
},
|
|
75
92
|
hints: {
|
|
76
93
|
launch: "mm-harness launch",
|
|
77
|
-
relaunch: "mm-harness launch --build"
|
|
94
|
+
relaunch: "mm-harness launch --build",
|
|
95
|
+
runtimeProbeRecovery: (target) => `mm-harness launch --adapter extension --target ${shellQuote(path.resolve(target))}`
|
|
78
96
|
}
|
|
79
97
|
};
|
|
80
98
|
export {
|
|
@@ -49,6 +49,8 @@ async function prepareMobile(target, opts = {}) {
|
|
|
49
49
|
return { status: EXIT.runtime, output: msg };
|
|
50
50
|
}
|
|
51
51
|
if (report.decision === "ready") {
|
|
52
|
+
const surface = await ensureMobileHumanSurface(target, platform, json);
|
|
53
|
+
if (surface.status !== 0) return surface;
|
|
52
54
|
const confirm = await dispatchAction(
|
|
53
55
|
{ id: "wait-for-bridge", cwd: target, argv: ["--max-polls", String(READY_BRIDGE_CONFIRM_POLLS)] },
|
|
54
56
|
target,
|
|
@@ -100,6 +102,8 @@ async function prepareMobile(target, opts = {}) {
|
|
|
100
102
|
return { status: EXIT.runtime, output: msg };
|
|
101
103
|
}
|
|
102
104
|
case "ready": {
|
|
105
|
+
const surface = await ensureMobileHumanSurface(target, platform, json);
|
|
106
|
+
if (surface.status !== 0) return surface;
|
|
103
107
|
const bridge = await dispatchAction({ id: "wait-for-bridge", cwd: target }, target, platform, json, preflightMode);
|
|
104
108
|
if (bridge.status !== 0) return bridge;
|
|
105
109
|
break;
|
|
@@ -128,6 +132,10 @@ function startMobileConsoleForwarder(target, platform, json) {
|
|
|
128
132
|
json
|
|
129
133
|
);
|
|
130
134
|
}
|
|
135
|
+
function ensureMobileHumanSurface(target, platform, json) {
|
|
136
|
+
if (platform !== "ios") return Promise.resolve({ status: 0, output: "" });
|
|
137
|
+
return dispatchAction({ id: "ensure-device-ui", cwd: target }, target, platform, json);
|
|
138
|
+
}
|
|
131
139
|
async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode) {
|
|
132
140
|
const resolved = path.resolve(target);
|
|
133
141
|
const actions = applyMobileMetroEnvPolicy(resolved, requestedActions);
|
|
@@ -184,6 +192,15 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
|
|
|
184
192
|
POD_PROBE_ENV
|
|
185
193
|
);
|
|
186
194
|
}
|
|
195
|
+
case "ensure-device-ui": {
|
|
196
|
+
const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
|
|
197
|
+
return spawnScriptStreaming(
|
|
198
|
+
leaf,
|
|
199
|
+
["--platform", platform, "--target", cwd, "--ui-only"],
|
|
200
|
+
target,
|
|
201
|
+
POD_PROBE_ENV
|
|
202
|
+
);
|
|
203
|
+
}
|
|
187
204
|
case "rebuild-native-dev-client": {
|
|
188
205
|
const msg = "rebuild-native-dev-client required: native module mismatch detected.\n Next: yarn start:ios or yarn start:android to rebuild the native dev client, then re-run mm-harness launch.";
|
|
189
206
|
if (!json) process.stderr.write(`${msg}
|
|
@@ -20,47 +20,32 @@ const SOURCE_PATHS = [
|
|
|
20
20
|
const ENV_INPUTS = [".js.env", ".env", ".env.local"];
|
|
21
21
|
const BASELINE_FILE = "mobile-source-baseline.json";
|
|
22
22
|
function mobileSourceFingerprint(target) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
hash.update("\0");
|
|
45
|
-
}
|
|
46
|
-
return { status: "ok", fingerprint: hash.digest("hex") };
|
|
47
|
-
} catch {
|
|
48
|
-
const resolved = path.resolve(target);
|
|
49
|
-
return {
|
|
50
|
-
fingerprint: null,
|
|
51
|
-
status: "unavailable",
|
|
52
|
-
error: {
|
|
53
|
-
code: "MOBILE_SOURCE_UNAVAILABLE",
|
|
54
|
-
message: `Mobile source fingerprint requires a readable Git checkout and source inputs: ${resolved}`,
|
|
55
|
-
userAction: "mm-harness doctor --adapter mobile --target <metamask-mobile-checkout>"
|
|
56
|
-
}
|
|
57
|
-
};
|
|
23
|
+
const hash = createHash("sha256");
|
|
24
|
+
hash.update(git(target, ["rev-parse", "HEAD"]));
|
|
25
|
+
hash.update(git(target, ["diff", "--no-ext-diff", "--binary", "HEAD", "--", ...SOURCE_PATHS]));
|
|
26
|
+
const untracked = git(target, [
|
|
27
|
+
"ls-files",
|
|
28
|
+
"--others",
|
|
29
|
+
"--exclude-standard",
|
|
30
|
+
"-z",
|
|
31
|
+
"--",
|
|
32
|
+
...SOURCE_PATHS
|
|
33
|
+
]).toString("utf8").split("\0").filter(Boolean).sort();
|
|
34
|
+
for (const relative of untracked) {
|
|
35
|
+
hash.update(`untracked\0${relative}\0`);
|
|
36
|
+
hash.update(fs.readFileSync(path.join(target, relative)));
|
|
37
|
+
hash.update("\0");
|
|
38
|
+
}
|
|
39
|
+
for (const relative of ENV_INPUTS) {
|
|
40
|
+
const absolute = path.join(target, relative);
|
|
41
|
+
hash.update(`env\0${relative}\0`);
|
|
42
|
+
hash.update(fs.existsSync(absolute) ? fs.readFileSync(absolute) : "<absent>");
|
|
43
|
+
hash.update("\0");
|
|
58
44
|
}
|
|
45
|
+
return hash.digest("hex");
|
|
59
46
|
}
|
|
60
47
|
function mobileSourceCheck(target) {
|
|
61
|
-
const
|
|
62
|
-
if (result.status === "unavailable") return result;
|
|
63
|
-
const { fingerprint } = result;
|
|
48
|
+
const fingerprint = mobileSourceFingerprint(target);
|
|
64
49
|
const baseline = readBaseline(target);
|
|
65
50
|
return {
|
|
66
51
|
fingerprint,
|
|
@@ -68,7 +53,7 @@ function mobileSourceCheck(target) {
|
|
|
68
53
|
};
|
|
69
54
|
}
|
|
70
55
|
function recordMobileSourceBaseline(target, expectedFingerprint) {
|
|
71
|
-
if (mobileSourceFingerprint(target)
|
|
56
|
+
if (mobileSourceFingerprint(target) !== expectedFingerprint) return false;
|
|
72
57
|
const marker = recipeRuntimePath(target, BASELINE_FILE);
|
|
73
58
|
const temporary = `${marker}.${process.pid}.${Date.now()}.tmp`;
|
|
74
59
|
const baseline = {
|
|
@@ -80,9 +65,9 @@ function recordMobileSourceBaseline(target, expectedFingerprint) {
|
|
|
80
65
|
try {
|
|
81
66
|
fs.writeFileSync(temporary, `${JSON.stringify(baseline, null, 2)}
|
|
82
67
|
`, { mode: 384 });
|
|
83
|
-
if (mobileSourceFingerprint(target)
|
|
68
|
+
if (mobileSourceFingerprint(target) !== expectedFingerprint) return false;
|
|
84
69
|
fs.renameSync(temporary, marker);
|
|
85
|
-
if (mobileSourceFingerprint(target)
|
|
70
|
+
if (mobileSourceFingerprint(target) !== expectedFingerprint) {
|
|
86
71
|
if (readBaseline(target)?.fingerprint === expectedFingerprint) fs.rmSync(marker, { force: true });
|
|
87
72
|
return false;
|
|
88
73
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { recipeRuntimePath, runnerDir } from "../../paths.js";
|
|
4
|
+
import { shellQuote } from "../../commands/parse-args.js";
|
|
4
5
|
import { resolveMobileSlotPorts } from "../slot-ports.js";
|
|
5
6
|
import { mobileRuntimeStatus } from "./prepare.js";
|
|
6
7
|
import { hasRunwayProvisionBaseline, provisionRunwayMobile } from "./provision.js";
|
|
@@ -20,6 +21,7 @@ const mobileSurface = {
|
|
|
20
21
|
decision: depsPending ? "launch" : report.decision,
|
|
21
22
|
reasonCode: depsPending ? "app-installed-deps-pending" : report.reasonCode,
|
|
22
23
|
reasons: depsPending ? ["Runway app is installed; JavaScript dependencies are pending until dispatch-time launch."] : report.reasons,
|
|
24
|
+
nextAction: report.decision === "ready" && !depsPending ? void 0 : `mm-harness launch ${platform} --adapter mobile --target ${shellQuote(path.resolve(target))}`,
|
|
23
25
|
deps: runwayProvisioned && report.checks?.deps?.status !== "current" ? "pending" : report.checks?.deps?.status,
|
|
24
26
|
devServer: { label: "metro", status: report.checks?.metro?.status ?? "unprobed" }
|
|
25
27
|
};
|
|
@@ -51,7 +53,8 @@ const mobileSurface = {
|
|
|
51
53
|
},
|
|
52
54
|
hints: {
|
|
53
55
|
launch: "mm-harness launch ios",
|
|
54
|
-
relaunch: "mm-harness launch ios"
|
|
56
|
+
relaunch: "mm-harness launch ios",
|
|
57
|
+
runtimeProbeRecovery: (target) => `mm-harness status --target ${shellQuote(path.resolve(target))} --json`
|
|
55
58
|
}
|
|
56
59
|
};
|
|
57
60
|
export {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
formatKvLines,
|
|
3
|
+
formatExtensionIdentityPorts,
|
|
3
4
|
formatExtensionSlotPorts,
|
|
4
5
|
inferSlotSuffix,
|
|
5
6
|
realRepoPath,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
resolveMobileSlotDefaults
|
|
13
14
|
} from "../../adapters/shared/resolve-slot-ports-core.mjs";
|
|
14
15
|
export {
|
|
16
|
+
formatExtensionIdentityPorts,
|
|
15
17
|
formatExtensionSlotPorts,
|
|
16
18
|
formatKvLines,
|
|
17
19
|
inferSlotSuffix,
|
|
@@ -3,8 +3,8 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { recipeRuntimeDir } from "../paths.js";
|
|
5
5
|
import {
|
|
6
|
+
formatExtensionIdentityPorts,
|
|
6
7
|
resolveDefaultExtensionPorts,
|
|
7
|
-
resolveExtensionRuntimeContext,
|
|
8
8
|
resolveMobileRuntimeContext,
|
|
9
9
|
resolveSlotPortsByRepo,
|
|
10
10
|
resolveMobileSlotDefaults
|
|
@@ -55,12 +55,10 @@ function resolveMobileSlotPorts(target) {
|
|
|
55
55
|
}
|
|
56
56
|
function resolveExtensionSlotPorts(target) {
|
|
57
57
|
const resolved = path.resolve(target);
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
const defOut = resolveDefaultExtensionPorts(resolved);
|
|
63
|
-
if (defOut?.trim()) applyKVLines(defOut, false);
|
|
58
|
+
const identity = formatExtensionIdentityPorts(resolved);
|
|
59
|
+
if (identity.trim()) applyKVLines(identity, true);
|
|
60
|
+
const fallback = resolveDefaultExtensionPorts(resolved);
|
|
61
|
+
if (fallback?.trim()) applyKVLines(fallback, false);
|
|
64
62
|
}
|
|
65
63
|
function extensionWatcherPids(target) {
|
|
66
64
|
const resolved = path.resolve(target);
|
|
@@ -78,7 +76,9 @@ function extensionWatcherPids(target) {
|
|
|
78
76
|
const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
|
|
79
77
|
if (!match) continue;
|
|
80
78
|
const [, pidStr, cmd] = match;
|
|
81
|
-
const
|
|
79
|
+
const executable = path.basename(cmd.trim().split(/\s+/u)[0] ?? "");
|
|
80
|
+
const isNodeProcess = executable === "node" || executable === "nodejs" || executable === "yarn";
|
|
81
|
+
const isWatcher = isNodeProcess && (/(?:^|\/)yarn start(?:\s|$)/u.test(cmd) || /(?:^|\/)yarn\.js webpack --watch(?:\s|$)/u.test(cmd) || /development\/webpack\/launch\.ts --watch(?:\s|$)/u.test(cmd));
|
|
82
82
|
if (!isWatcher) continue;
|
|
83
83
|
if (cmd.includes(resolved)) {
|
|
84
84
|
ownedPids.add(Number(pidStr));
|
|
@@ -105,32 +105,17 @@ function isExtensionWatcherLive(target) {
|
|
|
105
105
|
function stopExtensionWatcher(target) {
|
|
106
106
|
const runtimeAbs = path.join(path.resolve(target), recipeRuntimeDir());
|
|
107
107
|
const webpackPidFile = path.join(runtimeAbs, "recipe-harness-webpack.pid");
|
|
108
|
-
const syncPidFile = path.join(runtimeAbs, "recipe-harness-webpack-sync.pid");
|
|
109
108
|
const ownedPids = extensionWatcherPids(target);
|
|
110
|
-
try {
|
|
111
|
-
const syncPid = fs.readFileSync(syncPidFile, "utf8").trim();
|
|
112
|
-
if (/^\d+$/u.test(syncPid) && processAlive(Number(syncPid))) ownedPids.push(Number(syncPid));
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
109
|
for (const pid of ownedPids) {
|
|
116
|
-
try {
|
|
117
|
-
process.kill(-pid, "SIGTERM");
|
|
118
|
-
} catch {
|
|
119
|
-
}
|
|
120
110
|
try {
|
|
121
111
|
process.kill(pid, "SIGTERM");
|
|
122
112
|
} catch {
|
|
123
113
|
}
|
|
124
114
|
}
|
|
125
115
|
fs.rmSync(webpackPidFile, { force: true });
|
|
126
|
-
fs.rmSync(syncPidFile, { force: true });
|
|
127
116
|
if (ownedPids.length > 0) {
|
|
128
117
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2e3);
|
|
129
118
|
for (const pid of ownedPids) {
|
|
130
|
-
try {
|
|
131
|
-
process.kill(-pid, "SIGKILL");
|
|
132
|
-
} catch {
|
|
133
|
-
}
|
|
134
119
|
try {
|
|
135
120
|
process.kill(pid, "SIGKILL");
|
|
136
121
|
} catch {
|
|
@@ -142,9 +127,8 @@ function stopExtensionWatcher(target) {
|
|
|
142
127
|
function stopExtensionRuntime(target) {
|
|
143
128
|
const resolved = path.resolve(target);
|
|
144
129
|
const runtimeAbs = path.join(resolved, recipeRuntimeDir());
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
);
|
|
130
|
+
const configuredProfile = process.env.CHROME_USER_DATA_DIR || path.join(runtimeAbs, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
|
|
131
|
+
const profiles = process.env.CHROME_USER_DATA_DIR ? [path.resolve(configuredProfile)] : [configuredProfile, path.join(runtimeAbs, "chrome-profile-recipe"), path.join(runtimeAbs, "chrome-profile-pw")].map((value) => path.resolve(value));
|
|
148
132
|
let signalled = stopExtensionWatcher(resolved);
|
|
149
133
|
const ownedBrowserPids = /* @__PURE__ */ new Set();
|
|
150
134
|
try {
|
|
@@ -153,9 +137,7 @@ function stopExtensionRuntime(target) {
|
|
|
153
137
|
const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
|
|
154
138
|
if (!match) continue;
|
|
155
139
|
const pid = Number(match[1]);
|
|
156
|
-
if (pid !== process.pid && commandHasExactArg(match[2], "--user-data-dir", profile))
|
|
157
|
-
ownedBrowserPids.add(pid);
|
|
158
|
-
}
|
|
140
|
+
if (pid !== process.pid && profiles.some((profile) => commandHasExactArg(match[2], "--user-data-dir", profile))) ownedBrowserPids.add(pid);
|
|
159
141
|
}
|
|
160
142
|
} catch {
|
|
161
143
|
}
|
|
@@ -179,7 +161,6 @@ function stopExtensionRuntime(target) {
|
|
|
179
161
|
let removedMarkers = 0;
|
|
180
162
|
for (const name of [
|
|
181
163
|
"recipe-harness-webpack.pid",
|
|
182
|
-
"recipe-harness-webpack-sync.pid",
|
|
183
164
|
"webpack.pid",
|
|
184
165
|
"browser.pid",
|
|
185
166
|
"chromium.pid",
|
|
@@ -211,8 +192,8 @@ function processAlive(pid) {
|
|
|
211
192
|
try {
|
|
212
193
|
process.kill(pid, 0);
|
|
213
194
|
return true;
|
|
214
|
-
} catch {
|
|
215
|
-
return
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return error.code === "EPERM";
|
|
216
197
|
}
|
|
217
198
|
}
|
|
218
199
|
function commandHasExactArg(command, flag, value) {
|