@deeeed/metamask-harness 0.14.0 → 0.14.2
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 +31 -0
- package/adapters/extension/inject.mjs +1 -0
- package/adapters/extension/launch-browser.cjs +15 -3
- package/adapters/extension/lib/extension-id.cjs +36 -0
- package/adapters/extension/live.sh +5 -3
- package/adapters/extension/readiness.mjs +48 -0
- package/adapters/extension/reattach.sh +163 -55
- package/adapters/extension/sidepanel-toggle.sh +96 -24
- package/adapters/extension/verify.sh +11 -0
- package/adapters/extension/wallet-fixture-state.cjs +6 -11
- package/adapters/mobile/open-device.sh +32 -5
- package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
- package/dist/adapters/core/surface.js +1 -0
- package/dist/adapters/extension/runtime.js +14 -5
- package/dist/adapters/extension/surface.js +1 -0
- package/dist/adapters/mobile/provision.js +49 -4
- package/dist/adapters/mobile/surface.js +1 -0
- package/dist/adapters/slot-ports.js +11 -4
- package/dist/cli.js +4 -0
- package/dist/commands/call.js +19 -2
- package/dist/commands/check.js +326 -0
- package/dist/commands/core-readiness.js +75 -0
- package/dist/commands/device-target.js +113 -10
- package/dist/commands/doctor.js +30 -18
- package/dist/commands/launch/extension.js +105 -9
- package/dist/commands/launch/index.js +116 -15
- package/dist/commands/mobile-device-view.js +140 -0
- package/dist/commands/parse-args.js +4 -1
- package/dist/commands/recipe-quality.js +6 -2
- package/dist/commands/run-engine.js +28 -2
- package/dist/commands/run-report.js +115 -0
- package/dist/commands/run.js +113 -8
- package/dist/commands/shared.js +2 -1
- package/dist/commands/status-probe.js +9 -3
- package/dist/commands/status.js +40 -60
- package/dist/live-adapter-contract.js +5 -1
- package/dist/mm-harness-cli.js +61 -22
- package/docs/CLI-SPEC.md +25 -0
- package/library/actions/extension/platform/cdp.mjs +7 -4
- package/package.json +4 -4
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { detectAdapter } from "../harness.js";
|
|
5
|
+
import { EXIT, usageOut } from "./shared.js";
|
|
6
|
+
import { optionFlag, optionString, parseArgs } from "./parse-args.js";
|
|
7
|
+
const USAGE = "mm-harness check diff [--target <repo>] [--adapter mobile|extension|core] [--base <ref>] [--profile fast|full] [--artifacts-dir <dir>] [--json]";
|
|
8
|
+
const JS_TS_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$/u;
|
|
9
|
+
const FORMAT_EXT_RE = /\.(?:c|m)?jsx?$|\.tsx?$|\.json$|\.md$|\.css$|\.scss$|\.ya?ml$/u;
|
|
10
|
+
const TEST_FILE_RE = /(?:^|[./_-])(?:test|spec)\.(?:c|m)?jsx?$|(?:^|[./_-])(?:test|spec)\.tsx?$/u;
|
|
11
|
+
async function handleCheck(argv) {
|
|
12
|
+
const { positional, options } = parseArgs(argv, "check");
|
|
13
|
+
const action = positional[0];
|
|
14
|
+
const json = optionFlag(options, "json");
|
|
15
|
+
if (action !== "diff") {
|
|
16
|
+
const message = action ? `unknown action '${action}'` : "missing action";
|
|
17
|
+
return usageOut(json, "check", message, USAGE);
|
|
18
|
+
}
|
|
19
|
+
const target = path.resolve(optionString(options, "target") ?? optionString(options, "projectRoot") ?? process.cwd());
|
|
20
|
+
const explicitAdapter = optionString(options, "adapter") ?? optionString(options, "platform");
|
|
21
|
+
const detected = explicitAdapter ?? detectAdapter(target);
|
|
22
|
+
if (detected && !["mobile", "extension", "core"].includes(detected)) {
|
|
23
|
+
return usageOut(json, "check", `unsupported adapter '${detected}'`, USAGE);
|
|
24
|
+
}
|
|
25
|
+
const adapter = detected;
|
|
26
|
+
const profileInput = optionString(options, "profile");
|
|
27
|
+
if (profileInput && profileInput !== "fast" && profileInput !== "full") {
|
|
28
|
+
return usageOut(json, "check", "--profile must be fast or full", USAGE);
|
|
29
|
+
}
|
|
30
|
+
const profile = profileInput || "fast";
|
|
31
|
+
const explicitBaseRef = optionString(options, "base");
|
|
32
|
+
const base = explicitBaseRef ? { ref: explicitBaseRef, source: "flag" } : defaultBaseRef(target);
|
|
33
|
+
const baseRef = base.ref;
|
|
34
|
+
const artifactsDir = path.resolve(
|
|
35
|
+
optionString(options, "artifactsDir") ?? path.join(target, "temp", "recipe", "check-diff", timestampSlug())
|
|
36
|
+
);
|
|
37
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
38
|
+
if (!isGitRepo(target)) {
|
|
39
|
+
return failEnvelope({
|
|
40
|
+
json,
|
|
41
|
+
adapter,
|
|
42
|
+
target,
|
|
43
|
+
profile,
|
|
44
|
+
baseRef,
|
|
45
|
+
artifactsDir,
|
|
46
|
+
message: `${target} is not a git checkout`,
|
|
47
|
+
code: "CHECK_DIFF_NOT_GIT"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const packageJson = readPackageJson(target);
|
|
51
|
+
const mergeBase = resolveMergeBase(target, baseRef);
|
|
52
|
+
const changedFiles = listChangedFiles(target, mergeBase, artifactsDir);
|
|
53
|
+
const existingChangedFiles = changedFiles.filter((file) => fs.existsSync(path.join(target, file)));
|
|
54
|
+
const checks = [];
|
|
55
|
+
checks.push(
|
|
56
|
+
runToolCheck({
|
|
57
|
+
id: "eslint",
|
|
58
|
+
label: "Changed-file ESLint",
|
|
59
|
+
target,
|
|
60
|
+
artifactsDir,
|
|
61
|
+
files: existingChangedFiles.filter((file) => JS_TS_EXT_RE.test(file)),
|
|
62
|
+
command: executable(target, "eslint"),
|
|
63
|
+
args: ["--cache", "--cache-location", path.join(artifactsDir, "eslintcache"), "--max-warnings=0", "--no-warn-ignored"]
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
checks.push(
|
|
67
|
+
runToolCheck({
|
|
68
|
+
id: "prettier",
|
|
69
|
+
label: "Changed-file Prettier",
|
|
70
|
+
target,
|
|
71
|
+
artifactsDir,
|
|
72
|
+
files: existingChangedFiles.filter((file) => FORMAT_EXT_RE.test(file)),
|
|
73
|
+
command: executable(target, "prettier"),
|
|
74
|
+
args: ["--check"]
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
checks.push(
|
|
78
|
+
runToolCheck({
|
|
79
|
+
id: "jest",
|
|
80
|
+
label: "Changed test files",
|
|
81
|
+
target,
|
|
82
|
+
artifactsDir,
|
|
83
|
+
files: existingChangedFiles.filter((file) => TEST_FILE_RE.test(file)),
|
|
84
|
+
command: executable(target, "jest"),
|
|
85
|
+
args: ["--no-coverage"]
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
if (profile === "full") {
|
|
89
|
+
checks.push(runTypecheck({ target, artifactsDir, packageJson }));
|
|
90
|
+
} else {
|
|
91
|
+
checks.push({
|
|
92
|
+
id: "typecheck",
|
|
93
|
+
label: "TypeScript project check",
|
|
94
|
+
status: "skip",
|
|
95
|
+
reason: "profile=fast; run with --profile full for repo-wide typecheck"
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const status = checks.some((check) => check.status === "fail") ? "fail" : "pass";
|
|
99
|
+
const exitCode = status === "pass" ? EXIT.ok : EXIT.validation;
|
|
100
|
+
const summary = {
|
|
101
|
+
schemaVersion: 1,
|
|
102
|
+
command: "check",
|
|
103
|
+
action: "diff",
|
|
104
|
+
status,
|
|
105
|
+
exitCode,
|
|
106
|
+
adapter: adapter ?? null,
|
|
107
|
+
target,
|
|
108
|
+
profile,
|
|
109
|
+
baseRef,
|
|
110
|
+
baseSource: base.source,
|
|
111
|
+
baseLabel: base.label ?? null,
|
|
112
|
+
mergeBase,
|
|
113
|
+
changedFiles,
|
|
114
|
+
checks,
|
|
115
|
+
artifacts: {
|
|
116
|
+
dir: artifactsDir,
|
|
117
|
+
summaryJson: path.join(artifactsDir, "validation-summary.json"),
|
|
118
|
+
summaryMarkdown: path.join(artifactsDir, "validation-summary.md")
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
writeSummaryArtifacts(summary);
|
|
122
|
+
if (json) console.log(JSON.stringify(summary, null, 2));
|
|
123
|
+
else renderHuman(summary);
|
|
124
|
+
return exitCode;
|
|
125
|
+
}
|
|
126
|
+
function timestampSlug() {
|
|
127
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z");
|
|
128
|
+
}
|
|
129
|
+
function isGitRepo(target) {
|
|
130
|
+
return runCommand("git", ["rev-parse", "--is-inside-work-tree"], target).status === 0;
|
|
131
|
+
}
|
|
132
|
+
function defaultBaseRef(target) {
|
|
133
|
+
const prBase = githubPullRequestBase(target);
|
|
134
|
+
if (prBase) return prBase;
|
|
135
|
+
const remoteHead = runCommand("git", ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], target).stdout.trim();
|
|
136
|
+
if (remoteHead && verifyRef(target, remoteHead)) return { ref: remoteHead, source: "remote-head" };
|
|
137
|
+
for (const ref of ["origin/develop", "origin/main", "develop", "main", "HEAD"]) {
|
|
138
|
+
if (verifyRef(target, ref)) return { ref, source: "fallback" };
|
|
139
|
+
}
|
|
140
|
+
return { ref: "", source: "fallback" };
|
|
141
|
+
}
|
|
142
|
+
function githubPullRequestBase(target) {
|
|
143
|
+
const result = runCommand("gh", ["pr", "view", "--json", "baseRefName", "--jq", ".baseRefName"], target);
|
|
144
|
+
if (result.status !== 0) return null;
|
|
145
|
+
const baseName = result.stdout.trim();
|
|
146
|
+
if (!baseName) return null;
|
|
147
|
+
for (const ref of [`origin/${baseName}`, `upstream/${baseName}`, baseName]) {
|
|
148
|
+
if (verifyRef(target, ref)) return { ref, source: "github-pr", label: baseName };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function verifyRef(target, ref) {
|
|
153
|
+
return runCommand("git", ["rev-parse", "--verify", ref], target).status === 0;
|
|
154
|
+
}
|
|
155
|
+
function resolveMergeBase(target, baseRef) {
|
|
156
|
+
if (!baseRef) return null;
|
|
157
|
+
const result = runCommand("git", ["merge-base", "HEAD", baseRef], target);
|
|
158
|
+
if (result.status === 0) return result.stdout.trim() || null;
|
|
159
|
+
const verify = runCommand("git", ["rev-parse", "--verify", baseRef], target);
|
|
160
|
+
return verify.status === 0 ? verify.stdout.trim() || null : null;
|
|
161
|
+
}
|
|
162
|
+
function listChangedFiles(target, mergeBase, artifactsDir) {
|
|
163
|
+
const files = /* @__PURE__ */ new Set();
|
|
164
|
+
const addLines = (output) => {
|
|
165
|
+
for (const line of output.split("\n")) {
|
|
166
|
+
const file = line.trim();
|
|
167
|
+
if (file) files.add(file);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
if (mergeBase) {
|
|
171
|
+
addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", `${mergeBase}...HEAD`], target).stdout);
|
|
172
|
+
}
|
|
173
|
+
addLines(runCommand("git", ["diff", "--name-only", "--diff-filter=ACMR", "HEAD"], target).stdout);
|
|
174
|
+
addLines(runCommand("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], target).stdout);
|
|
175
|
+
addLines(runCommand("git", ["ls-files", "--others", "--exclude-standard"], target).stdout);
|
|
176
|
+
return [...files].filter((file) => !isUnderArtifactsDir(target, artifactsDir, file)).sort();
|
|
177
|
+
}
|
|
178
|
+
function isUnderArtifactsDir(target, artifactsDir, file) {
|
|
179
|
+
const artifactRel = normalizeRelativePath(path.relative(path.resolve(target), path.resolve(artifactsDir)));
|
|
180
|
+
if (!artifactRel || artifactRel.startsWith("../") || artifactRel === "..") return false;
|
|
181
|
+
const normalized = normalizeRelativePath(file);
|
|
182
|
+
return normalized === artifactRel || normalized.startsWith(`${artifactRel}/`);
|
|
183
|
+
}
|
|
184
|
+
function normalizeRelativePath(value) {
|
|
185
|
+
return value.split(path.sep).join("/");
|
|
186
|
+
}
|
|
187
|
+
function executable(target, name) {
|
|
188
|
+
const local = path.join(target, "node_modules", ".bin", name);
|
|
189
|
+
return fs.existsSync(local) ? local : null;
|
|
190
|
+
}
|
|
191
|
+
function runToolCheck(input) {
|
|
192
|
+
if (input.files.length === 0) {
|
|
193
|
+
return { id: input.id, label: input.label, status: "skip", reason: "no changed matching files" };
|
|
194
|
+
}
|
|
195
|
+
if (!input.command) {
|
|
196
|
+
return { id: input.id, label: input.label, status: "skip", reason: `${input.id} binary not found in node_modules/.bin` };
|
|
197
|
+
}
|
|
198
|
+
return runCheckCommand(input.id, input.label, input.target, input.artifactsDir, input.command, [...input.args, ...input.files]);
|
|
199
|
+
}
|
|
200
|
+
function runTypecheck(input) {
|
|
201
|
+
const scripts = input.packageJson?.scripts && typeof input.packageJson.scripts === "object" ? input.packageJson.scripts : {};
|
|
202
|
+
if (typeof scripts["lint:tsc"] === "string") {
|
|
203
|
+
return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, "yarn", ["lint:tsc"]);
|
|
204
|
+
}
|
|
205
|
+
const tsc = executable(input.target, "tsc");
|
|
206
|
+
if (tsc) {
|
|
207
|
+
return runCheckCommand("typecheck", "TypeScript project check", input.target, input.artifactsDir, tsc, ["--noEmit"]);
|
|
208
|
+
}
|
|
209
|
+
return { id: "typecheck", label: "TypeScript project check", status: "skip", reason: "no lint:tsc script or tsc binary found" };
|
|
210
|
+
}
|
|
211
|
+
function runCheckCommand(id, label, target, artifactsDir, command, args) {
|
|
212
|
+
const result = runCommand(command, args, target);
|
|
213
|
+
const logPath = path.join(artifactsDir, `${id}.log`);
|
|
214
|
+
fs.writeFileSync(logPath, renderCommandLog(command, args, result));
|
|
215
|
+
return {
|
|
216
|
+
id,
|
|
217
|
+
label,
|
|
218
|
+
status: result.status === 0 ? "pass" : "fail",
|
|
219
|
+
command: [command, ...args].join(" "),
|
|
220
|
+
exitCode: result.status,
|
|
221
|
+
logPath,
|
|
222
|
+
durationMs: result.durationMs
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function runCommand(command, args, cwd) {
|
|
226
|
+
const started = Date.now();
|
|
227
|
+
const result = spawnSync(command, args, {
|
|
228
|
+
cwd,
|
|
229
|
+
encoding: "utf8",
|
|
230
|
+
env: process.env,
|
|
231
|
+
maxBuffer: 64 * 1024 * 1024
|
|
232
|
+
});
|
|
233
|
+
const status = result.error ? 127 : result.status ?? 1;
|
|
234
|
+
return {
|
|
235
|
+
status,
|
|
236
|
+
stdout: result.stdout ?? "",
|
|
237
|
+
stderr: result.error ? String(result.error) : result.stderr ?? "",
|
|
238
|
+
durationMs: Date.now() - started
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function renderCommandLog(command, args, result) {
|
|
242
|
+
return [
|
|
243
|
+
`$ ${[command, ...args].join(" ")}`,
|
|
244
|
+
`exit: ${result.status}`,
|
|
245
|
+
`duration_ms: ${result.durationMs}`,
|
|
246
|
+
"",
|
|
247
|
+
"--- stdout ---",
|
|
248
|
+
result.stdout.trimEnd(),
|
|
249
|
+
"",
|
|
250
|
+
"--- stderr ---",
|
|
251
|
+
result.stderr.trimEnd(),
|
|
252
|
+
""
|
|
253
|
+
].join("\n");
|
|
254
|
+
}
|
|
255
|
+
function readPackageJson(target) {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(fs.readFileSync(path.join(target, "package.json"), "utf8"));
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function writeSummaryArtifacts(summary) {
|
|
263
|
+
fs.writeFileSync(summary.artifacts.summaryJson, `${JSON.stringify(summary, null, 2)}
|
|
264
|
+
`);
|
|
265
|
+
fs.writeFileSync(summary.artifacts.summaryMarkdown, renderMarkdown(summary));
|
|
266
|
+
}
|
|
267
|
+
function renderMarkdown(summary) {
|
|
268
|
+
const lines = [
|
|
269
|
+
"# mm-harness check diff",
|
|
270
|
+
"",
|
|
271
|
+
`Verdict: ${summary.status}`,
|
|
272
|
+
`Profile: ${summary.profile}`,
|
|
273
|
+
`Base: ${summary.baseRef || "(none)"} (${summary.baseSource || "unknown"}${summary.baseLabel ? `: ${summary.baseLabel}` : ""})`,
|
|
274
|
+
`Changed files: ${summary.changedFiles.length}`,
|
|
275
|
+
"",
|
|
276
|
+
"## Checks",
|
|
277
|
+
""
|
|
278
|
+
];
|
|
279
|
+
for (const check of summary.checks) {
|
|
280
|
+
lines.push(`- ${check.status.toUpperCase()} ${check.id}${check.reason ? ` \u2014 ${check.reason}` : ""}${check.logPath ? ` (${check.logPath})` : ""}`);
|
|
281
|
+
}
|
|
282
|
+
lines.push("", "## Changed Files", "");
|
|
283
|
+
for (const file of summary.changedFiles) lines.push(`- ${file}`);
|
|
284
|
+
return `${lines.join("\n")}
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
function renderHuman(summary) {
|
|
288
|
+
const mark = summary.status === "pass" ? "\u2713" : "\u2717";
|
|
289
|
+
const baseText = `${summary.baseRef || "(none)"}${summary.baseSource ? ` [${summary.baseSource}]` : ""}`;
|
|
290
|
+
console.log(`${mark} check diff: ${summary.status} \xB7 ${summary.changedFiles.length} changed file(s) \xB7 profile=${summary.profile} \xB7 base=${baseText}`);
|
|
291
|
+
if (summary.changedFiles.length > 0) {
|
|
292
|
+
console.log("Changed files:");
|
|
293
|
+
for (const file of summary.changedFiles.slice(0, 100)) console.log(` - ${file}`);
|
|
294
|
+
if (summary.changedFiles.length > 100) console.log(` ... ${summary.changedFiles.length - 100} more; full list in validation-summary.json`);
|
|
295
|
+
}
|
|
296
|
+
for (const check of summary.checks) {
|
|
297
|
+
const prefix = check.status === "pass" ? "\u2713" : check.status === "fail" ? "\u2717" : "-";
|
|
298
|
+
console.log(` ${prefix} ${check.id}: ${check.status}${check.reason ? ` (${check.reason})` : ""}`);
|
|
299
|
+
if (check.status === "fail" && check.logPath) console.log(` log: ${check.logPath}`);
|
|
300
|
+
}
|
|
301
|
+
console.log(`Artifacts: ${summary.artifacts.summaryJson}`);
|
|
302
|
+
}
|
|
303
|
+
function failEnvelope(input) {
|
|
304
|
+
const exitCode = EXIT.usage;
|
|
305
|
+
const body = {
|
|
306
|
+
schemaVersion: 1,
|
|
307
|
+
command: "check",
|
|
308
|
+
action: "diff",
|
|
309
|
+
status: "fail",
|
|
310
|
+
exitCode,
|
|
311
|
+
adapter: input.adapter ?? null,
|
|
312
|
+
target: input.target,
|
|
313
|
+
profile: input.profile,
|
|
314
|
+
baseRef: input.baseRef,
|
|
315
|
+
baseSource: "unknown",
|
|
316
|
+
artifacts: { dir: input.artifactsDir },
|
|
317
|
+
error: { code: input.code, message: input.message }
|
|
318
|
+
};
|
|
319
|
+
if (input.json) console.log(JSON.stringify(body, null, 2));
|
|
320
|
+
else console.error(`\u2717 check diff: ${input.message}
|
|
321
|
+
Next: ${USAGE}`);
|
|
322
|
+
return exitCode;
|
|
323
|
+
}
|
|
324
|
+
export {
|
|
325
|
+
handleCheck
|
|
326
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const requireFromHarness = createRequire(import.meta.url);
|
|
5
|
+
const CORE_RUNTIME_DEPS = ["immer"];
|
|
6
|
+
function coreDependencyBlock(target) {
|
|
7
|
+
const resolved = path.resolve(target);
|
|
8
|
+
const nodeLinker = readYarnNodeLinker(resolved);
|
|
9
|
+
const installAction = `cd ${shellQuote(resolved)} && yarn install --immutable`;
|
|
10
|
+
if (nodeLinker === "pnp") {
|
|
11
|
+
if (!fs.existsSync(path.join(resolved, ".pnp.cjs"))) {
|
|
12
|
+
return {
|
|
13
|
+
code: "CORE_DEPS_MISSING",
|
|
14
|
+
message: "core dependencies are not installed for Yarn PnP (.pnp.cjs is missing).",
|
|
15
|
+
userAction: installAction
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
if (!fs.existsSync(path.join(resolved, "node_modules"))) {
|
|
21
|
+
return {
|
|
22
|
+
code: "CORE_DEPS_MISSING",
|
|
23
|
+
message: nodeLinker === "node-modules" ? "core dependencies are not installed (nodeLinker requires node_modules, but node_modules is missing)." : "core dependencies are not installed (node_modules is missing).",
|
|
24
|
+
userAction: installAction
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (!fs.existsSync(path.join(resolved, "node_modules/.bin/tsx"))) {
|
|
28
|
+
return {
|
|
29
|
+
code: "CORE_DEPS_INCOMPLETE",
|
|
30
|
+
message: "core dependencies are incomplete (node_modules/.bin/tsx is missing).",
|
|
31
|
+
userAction: installAction
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
for (const dependency of CORE_RUNTIME_DEPS) {
|
|
35
|
+
if (!canResolveFromTarget(dependency, resolved)) {
|
|
36
|
+
return {
|
|
37
|
+
code: "CORE_DEPS_INCOMPLETE",
|
|
38
|
+
message: `core dependencies are incomplete (cannot resolve ${dependency} from the target checkout).`,
|
|
39
|
+
userAction: installAction
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function corePnpNodeOptions(target, current) {
|
|
46
|
+
if (readYarnNodeLinker(target) !== "pnp") return current;
|
|
47
|
+
const pnp = path.join(path.resolve(target), ".pnp.cjs");
|
|
48
|
+
if (!fs.existsSync(pnp)) return current;
|
|
49
|
+
const pnpOption = `--require ${pnp}`;
|
|
50
|
+
return current ? `${current} ${pnpOption}` : pnpOption;
|
|
51
|
+
}
|
|
52
|
+
function canResolveFromTarget(specifier, target) {
|
|
53
|
+
try {
|
|
54
|
+
requireFromHarness.resolve(specifier, { paths: [target] });
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function readYarnNodeLinker(target) {
|
|
61
|
+
try {
|
|
62
|
+
const yarnrc = fs.readFileSync(path.join(target, ".yarnrc.yml"), "utf8");
|
|
63
|
+
const match = /^nodeLinker:\s*["']?([^"'\s#]+)["']?/mu.exec(yarnrc);
|
|
64
|
+
return match?.[1];
|
|
65
|
+
} catch {
|
|
66
|
+
return void 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function shellQuote(value) {
|
|
70
|
+
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
coreDependencyBlock,
|
|
74
|
+
corePnpNodeOptions
|
|
75
|
+
};
|
|
@@ -49,7 +49,13 @@ function renderDeviceList(devices, out) {
|
|
|
49
49
|
}
|
|
50
50
|
function deviceSelected(device) {
|
|
51
51
|
if (device.platform === "android") {
|
|
52
|
-
|
|
52
|
+
if (process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id) return true;
|
|
53
|
+
const pinName = process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || "";
|
|
54
|
+
if (!pinName) return false;
|
|
55
|
+
if (pinName === device.id) return true;
|
|
56
|
+
const name = device.name ? normalizeDeviceName(device.name) : "";
|
|
57
|
+
const normalizedPin = normalizeDeviceName(pinName);
|
|
58
|
+
return Boolean(name && (normalizedPin === name || normalizedPin.startsWith(`${name} -`)));
|
|
53
59
|
}
|
|
54
60
|
return process.env.IOS_SIMULATOR === device.id || process.env.SIM_UDID === device.id || process.env.IOS_SIMULATOR === device.name;
|
|
55
61
|
}
|
|
@@ -58,8 +64,20 @@ function isTargetable(device) {
|
|
|
58
64
|
if (device.platform === "ios") return device.state === "Booted";
|
|
59
65
|
return false;
|
|
60
66
|
}
|
|
67
|
+
function mobilePlatformPreference(options) {
|
|
68
|
+
const platform = optionString(options, "platform");
|
|
69
|
+
return platform === "ios" || platform === "android" ? platform : void 0;
|
|
70
|
+
}
|
|
71
|
+
function configuredPinForPlatform(platform) {
|
|
72
|
+
if (platform === "ios") return process.env.IOS_SIMULATOR || process.env.SIM_UDID || void 0;
|
|
73
|
+
return process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || process.env.ANDROID_TARGET_DEVICE_NAME || process.env.ANDROID_DEVICE || void 0;
|
|
74
|
+
}
|
|
75
|
+
function oppositePlatform(platform) {
|
|
76
|
+
return platform === "ios" ? "android" : "ios";
|
|
77
|
+
}
|
|
61
78
|
function applyDeviceTargeting(command, adapter, options, opts) {
|
|
62
79
|
const device = optionString(options, "device");
|
|
80
|
+
const platformPreference = mobilePlatformPreference(options);
|
|
63
81
|
if (adapter !== "mobile") {
|
|
64
82
|
if (device !== void 0) {
|
|
65
83
|
return {
|
|
@@ -73,15 +91,26 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
73
91
|
}
|
|
74
92
|
if (device !== void 0) {
|
|
75
93
|
const connected2 = listConnectedDevices();
|
|
94
|
+
const setResolvedDevice = (matched) => {
|
|
95
|
+
if (platformPreference && matched.platform !== platformPreference) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
code: "DEVICE_PLATFORM_CONFLICT",
|
|
99
|
+
message: `--device ${device} resolves to ${matched.platform}, but --platform ${platformPreference} was requested.
|
|
100
|
+
To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device.`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
|
|
104
|
+
else setIosDeviceEnv(matched.id, matched.name);
|
|
105
|
+
return { ok: true };
|
|
106
|
+
};
|
|
76
107
|
const androidById = connected2.find((d) => d.platform === "android" && d.id === device);
|
|
77
108
|
if (androidById) {
|
|
78
|
-
|
|
79
|
-
return { ok: true };
|
|
109
|
+
return setResolvedDevice(androidById);
|
|
80
110
|
}
|
|
81
111
|
const iosById = connected2.find((d) => d.platform === "ios" && d.id === device);
|
|
82
112
|
if (iosById) {
|
|
83
|
-
|
|
84
|
-
return { ok: true };
|
|
113
|
+
return setResolvedDevice(iosById);
|
|
85
114
|
}
|
|
86
115
|
const normalizedDevice = normalizeDeviceName(device);
|
|
87
116
|
const byName = connected2.filter((d) => {
|
|
@@ -90,10 +119,7 @@ function applyDeviceTargeting(command, adapter, options, opts) {
|
|
|
90
119
|
return name === device || normalizedName === normalizedDevice || d.platform === "android" && normalizedDevice.startsWith(`${normalizedName} -`);
|
|
91
120
|
});
|
|
92
121
|
if (byName.length === 1) {
|
|
93
|
-
|
|
94
|
-
if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
|
|
95
|
-
else setIosDeviceEnv(matched.id, matched.name);
|
|
96
|
-
return { ok: true };
|
|
122
|
+
return setResolvedDevice(byName[0]);
|
|
97
123
|
}
|
|
98
124
|
if (byName.length > 1) {
|
|
99
125
|
return {
|
|
@@ -113,6 +139,75 @@ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (
|
|
|
113
139
|
}
|
|
114
140
|
const connected = listConnectedDevices();
|
|
115
141
|
const targetable = connected.filter(isTargetable);
|
|
142
|
+
if (platformPreference) {
|
|
143
|
+
const selectedTargetable2 = targetable.filter(deviceSelected);
|
|
144
|
+
const platformTargetable = targetable.filter((d) => d.platform === platformPreference);
|
|
145
|
+
const selectedPlatformTargetable = platformTargetable.filter(deviceSelected);
|
|
146
|
+
const configuredPin = configuredPinForPlatform(platformPreference);
|
|
147
|
+
const oppositePin = configuredPinForPlatform(oppositePlatform(platformPreference));
|
|
148
|
+
if (selectedPlatformTargetable.length === 1) {
|
|
149
|
+
const selected = selectedPlatformTargetable[0];
|
|
150
|
+
if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
|
|
151
|
+
else setIosDeviceEnv(selected.id, selected.name);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
}
|
|
154
|
+
if (selectedTargetable2.length > 0) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
code: "DEVICE_PLATFORM_CONFLICT",
|
|
158
|
+
message: `--platform ${platformPreference} conflicts with the current slot-selected device.
|
|
159
|
+
Selected devices:
|
|
160
|
+
${formatConnectedDevices(selectedTargetable2)}
|
|
161
|
+
To use a different connected device intentionally, pass --device <id>.`
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!configuredPin && oppositePin) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
code: "DEVICE_PLATFORM_CONFLICT",
|
|
168
|
+
message: `--platform ${platformPreference} conflicts with the configured slot target.
|
|
169
|
+
Configured ${oppositePlatform(platformPreference)} target: ${oppositePin}
|
|
170
|
+
To use a different connected device intentionally, pass --device <id>.`
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (configuredPin) {
|
|
174
|
+
if (command === "launch" || !opts.gate) return { ok: true };
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
code: "DEVICE_PLATFORM_PIN_NOT_READY",
|
|
178
|
+
message: `--platform ${platformPreference} matches the configured slot target, but that target is not ready.
|
|
179
|
+
Configured target: ${configuredPin}
|
|
180
|
+
Connected devices:
|
|
181
|
+
${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
|
|
182
|
+
Next: run mm-harness launch ${platformPreference}, or pass --device <id> to intentionally use another target.`
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (platformTargetable.length === 1) {
|
|
186
|
+
const selected = platformTargetable[0];
|
|
187
|
+
if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
|
|
188
|
+
else setIosDeviceEnv(selected.id, selected.name);
|
|
189
|
+
return { ok: true };
|
|
190
|
+
}
|
|
191
|
+
if (platformTargetable.length === 0) {
|
|
192
|
+
if (command === "launch" && platformPreference === "ios") return { ok: true };
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
code: "DEVICE_PLATFORM_NOT_FOUND",
|
|
196
|
+
message: `--platform ${platformPreference} did not match any ready connected device.
|
|
197
|
+
` + (connected.length > 0 ? ` Connected devices:
|
|
198
|
+
${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
code: "DEVICE_PLATFORM_AMBIGUOUS",
|
|
204
|
+
message: `${platformTargetable.length} ${platformPreference} devices available \u2014 ${command} needs exactly one target.
|
|
205
|
+
Connected devices:
|
|
206
|
+
${formatConnectedDevices(connected)}
|
|
207
|
+
Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
|
|
208
|
+
--device ${d.id}`).join("")}`
|
|
209
|
+
};
|
|
210
|
+
}
|
|
116
211
|
const selectedTargetable = targetable.filter(deviceSelected);
|
|
117
212
|
if (selectedTargetable.length === 1) {
|
|
118
213
|
const selected = selectedTargetable[0];
|
|
@@ -134,9 +229,17 @@ ${formatConnectedDevices(connected)}
|
|
|
134
229
|
}
|
|
135
230
|
return { ok: true };
|
|
136
231
|
}
|
|
232
|
+
function scopedDevices(devices, allDevices = false) {
|
|
233
|
+
const withSelection = devices.map((device) => ({ ...device, selected: deviceSelected(device) }));
|
|
234
|
+
if (allDevices) return withSelection;
|
|
235
|
+
const selected = withSelection.filter((device) => device.selected);
|
|
236
|
+
return selected.length > 0 ? selected : withSelection;
|
|
237
|
+
}
|
|
137
238
|
export {
|
|
138
239
|
applyDeviceTargeting,
|
|
139
240
|
deviceSelected,
|
|
140
241
|
formatConnectedDevices,
|
|
141
|
-
|
|
242
|
+
mobilePlatformPreference,
|
|
243
|
+
renderDeviceList,
|
|
244
|
+
scopedDevices
|
|
142
245
|
};
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,8 +7,14 @@ import { assertAdapter, runnerDir } from "../paths.js";
|
|
|
7
7
|
import { getAdapterSurface } from "../adapters/surface.js";
|
|
8
8
|
import { loadActionManifest, validateManifest } from "../manifest.js";
|
|
9
9
|
import { ensureOverlay, newHealState, recipeRunning } from "../heal-bounds.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { applyDeviceTargeting } from "./device-target.js";
|
|
11
|
+
import {
|
|
12
|
+
mobileDeviceView,
|
|
13
|
+
mobileDeviceLiveView,
|
|
14
|
+
renderAdditionalReachableDevices,
|
|
15
|
+
renderMobileDeviceList,
|
|
16
|
+
renderMobileLiveBlock
|
|
17
|
+
} from "./mobile-device-view.js";
|
|
12
18
|
import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
|
|
13
19
|
import {
|
|
14
20
|
actionManifestPathOption,
|
|
@@ -35,18 +41,14 @@ async function handleDoctor({ options }) {
|
|
|
35
41
|
const target = targetPath(options);
|
|
36
42
|
const json = optionFlag(options, "json");
|
|
37
43
|
const expectLive = optionFlag(options, "expectLive");
|
|
38
|
-
const
|
|
44
|
+
const allDevices = optionFlag(options, "allDevices");
|
|
45
|
+
const platformOption = optionString(options, "platform");
|
|
46
|
+
const explicitAdapter = optionString(options, "adapter") ?? (platformOption === "ios" || platformOption === "android" ? "mobile" : platformOption);
|
|
39
47
|
const adapter = explicitAdapter ?? detectAdapter(target);
|
|
40
48
|
if (!adapter) {
|
|
41
49
|
return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
|
|
42
50
|
}
|
|
43
51
|
assertAdapter(adapter);
|
|
44
|
-
const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
|
|
45
|
-
if ("code" in dtResult) {
|
|
46
|
-
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
47
|
-
else console.error(`\u2717 doctor: ${dtResult.message}`);
|
|
48
|
-
return EXIT.usage;
|
|
49
|
-
}
|
|
50
52
|
const actionManifestPath = actionManifestPathOption(options, adapter);
|
|
51
53
|
const manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
|
|
52
54
|
const manifestValidation = await validateManifest(manifest);
|
|
@@ -62,15 +64,24 @@ async function handleDoctor({ options }) {
|
|
|
62
64
|
try {
|
|
63
65
|
const surface = getAdapterSurface(adapter);
|
|
64
66
|
surface.resolveSlotPorts(target);
|
|
67
|
+
const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
|
|
68
|
+
if ("code" in dtResult) {
|
|
69
|
+
if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
|
|
70
|
+
else console.error(`\u2717 doctor: ${dtResult.message}`);
|
|
71
|
+
return EXIT.usage;
|
|
72
|
+
}
|
|
65
73
|
applyDoctorRuntimePorts(options);
|
|
66
74
|
runtime = await surface.runtimeStatus(target);
|
|
67
75
|
} catch {
|
|
68
76
|
}
|
|
69
77
|
const orphanMetros = adapter === "mobile" ? detectOrphanMetros(target, process.env.WATCHER_PORT) : [];
|
|
70
78
|
const capture = captureHelperHealth();
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
|
|
80
|
+
const liveView = deviceView ? await mobileDeviceLiveView(target, deviceView) : null;
|
|
81
|
+
const devices = liveView?.devicesWithLive ?? deviceView?.devices ?? [];
|
|
82
|
+
const additionalReachableDevices = liveView?.additionalReachableDevices ?? [];
|
|
83
|
+
if (expectLive) return emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json);
|
|
84
|
+
if (json) console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
|
|
74
85
|
else {
|
|
75
86
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
76
87
|
const stateStyle = (value, good) => value === good ? "ok" : "warn";
|
|
@@ -100,17 +111,18 @@ async function handleDoctor({ options }) {
|
|
|
100
111
|
console.log(` ${out("dim", "Next: grant Screen Recording (System Settings \u2192 Privacy & Security \u2192 Screen Recording), or run: capture-helper doctor --open-permissions")}`);
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
|
-
if (adapter === "mobile")
|
|
114
|
+
if (adapter === "mobile") {
|
|
115
|
+
if (deviceView) renderMobileDeviceList(deviceView, out);
|
|
116
|
+
if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
|
|
117
|
+
if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
|
|
118
|
+
}
|
|
104
119
|
}
|
|
105
120
|
return result.status === "pass" ? 0 : 1;
|
|
106
121
|
}
|
|
107
|
-
function
|
|
108
|
-
return listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) }));
|
|
109
|
-
}
|
|
110
|
-
function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, json) {
|
|
122
|
+
function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json) {
|
|
111
123
|
const live = runtime?.decision === "ready";
|
|
112
124
|
if (json) {
|
|
113
|
-
console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices }, null, 2));
|
|
125
|
+
console.log(JSON.stringify({ ...result, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
|
|
114
126
|
return live ? EXIT.ok : EXIT.runtime;
|
|
115
127
|
}
|
|
116
128
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|