@deeeed/metamask-harness 0.14.0 → 0.14.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +0 -0
  6. package/adapters/extension/inject.mjs +1 -0
  7. package/adapters/extension/launch-browser.cjs +15 -3
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/lib/extension-id.cjs +36 -0
  10. package/adapters/extension/live.sh +5 -3
  11. package/adapters/extension/readiness.mjs +48 -0
  12. package/adapters/extension/reattach.sh +163 -55
  13. package/adapters/extension/refresh-build.sh +0 -0
  14. package/adapters/extension/seed-fixture.sh +0 -0
  15. package/adapters/extension/sidepanel-toggle.sh +96 -24
  16. package/adapters/extension/snapshot-dist.sh +0 -0
  17. package/adapters/extension/start-watch.sh +0 -0
  18. package/adapters/extension/verify.sh +11 -0
  19. package/adapters/extension/wallet-fixture-state.cjs +6 -11
  20. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +0 -0
  21. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  22. package/adapters/mobile/cleanup.sh +0 -0
  23. package/adapters/mobile/inject.sh +0 -0
  24. package/adapters/mobile/lib/metro-listener.sh +0 -0
  25. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  26. package/adapters/mobile/open-device.sh +32 -5
  27. package/adapters/mobile/prewarm-bundle.sh +0 -0
  28. package/adapters/mobile/start-metro.sh +0 -0
  29. package/adapters/mobile/stop-metro.sh +0 -0
  30. package/adapters/mobile/verify.sh +0 -0
  31. package/adapters/mobile/wait-for-bridge.sh +0 -0
  32. package/adapters/mobile/yarn-setup.sh +0 -0
  33. package/adapters/shared/activate-repo-node.sh +0 -0
  34. package/adapters/shared/activate-repo-ruby.sh +0 -0
  35. package/adapters/shared/cli-ux.sh +0 -0
  36. package/adapters/shared/ensure-runner-deps.sh +0 -0
  37. package/adapters/shared/harness-path.sh +0 -0
  38. package/adapters/shared/hash-helpers.sh +0 -0
  39. package/adapters/shared/json-field.sh +0 -0
  40. package/adapters/shared/open-log-window.sh +0 -0
  41. package/adapters/shared/reap-checkout-metros.sh +0 -0
  42. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  43. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  44. package/adapters/shared/resolve-slot-ports-core.mjs +14 -4
  45. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  46. package/adapters/shared/resolve-slot-ports.sh +0 -0
  47. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  48. package/adapters/shared/tmux-session.sh +0 -0
  49. package/dist/adapters/core/surface.js +1 -0
  50. package/dist/adapters/extension/runtime.js +14 -5
  51. package/dist/adapters/extension/surface.js +1 -0
  52. package/dist/adapters/mobile/provision.js +49 -4
  53. package/dist/adapters/mobile/surface.js +1 -0
  54. package/dist/adapters/slot-ports.js +11 -4
  55. package/dist/cli.js +4 -0
  56. package/dist/commands/call.js +19 -2
  57. package/dist/commands/check.js +326 -0
  58. package/dist/commands/core-readiness.js +75 -0
  59. package/dist/commands/device-target.js +113 -10
  60. package/dist/commands/doctor.js +30 -18
  61. package/dist/commands/launch/extension.js +105 -9
  62. package/dist/commands/launch/index.js +116 -15
  63. package/dist/commands/mobile-device-view.js +140 -0
  64. package/dist/commands/parse-args.js +4 -1
  65. package/dist/commands/recipe-quality.js +6 -2
  66. package/dist/commands/run-engine.js +28 -2
  67. package/dist/commands/run-report.js +115 -0
  68. package/dist/commands/run.js +113 -8
  69. package/dist/commands/shared.js +2 -1
  70. package/dist/commands/status-probe.js +9 -3
  71. package/dist/commands/status.js +40 -60
  72. package/dist/live-adapter-contract.js +5 -1
  73. package/dist/mm-harness-cli.js +61 -22
  74. package/docs/CLI-SPEC.md +25 -0
  75. package/library/actions/extension/platform/cdp.mjs +7 -4
  76. package/package.json +2 -2
  77. package/scripts/completions.sh +0 -0
  78. package/scripts/install-completions.sh +0 -0
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { handleDebug } from "./commands/debug.js";
19
19
  import { handleFixtures } from "./commands/fixtures.js";
20
20
  import { handleRecipeQuality } from "./commands/recipe-quality.js";
21
21
  import { handleStatus } from "./commands/status.js";
22
+ import { handleCheck } from "./commands/check.js";
22
23
  import { parseArgs, targetPath } from "./commands/parse-args.js";
23
24
  import { runOneNode } from "./commands/run-engine.js";
24
25
  const COMMANDS = {
@@ -56,6 +57,8 @@ PROVE \u2014 run recipes and inspect capabilities:
56
57
  mm-harness flows list
57
58
  doctor Readiness check for a checkout (no app launch).
58
59
  mm-harness doctor
60
+ check Run bounded repo-local checks for an active diff.
61
+ mm-harness check diff --profile fast --artifacts-dir artifacts/validation
59
62
  actions Describe the actions a manifest declares.
60
63
  mm-harness actions --adapter mobile
61
64
  manifest Print/validate the action manifest for an adapter.
@@ -127,6 +130,7 @@ async function main(argv) {
127
130
  if (command === "debug") return handleDebug(argv.slice(1));
128
131
  if (command === "fixtures") return handleFixtures(argv.slice(1), { runOneNode });
129
132
  if (command === "recipe-quality") return handleRecipeQuality(argv.slice(1));
133
+ if (command === "check") return handleCheck(argv.slice(1));
130
134
  const handler = COMMANDS[command];
131
135
  if (!handler) throw new Error(`Unknown command: ${command}`);
132
136
  return handler(parseArgs(argv.slice(1), command));
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { loadActionManifest } from "../manifest.js";
6
6
  import { importRecipeProtocol } from "../paths.js";
7
7
  import { recipeRunning } from "../heal-bounds.js";
8
+ import { color } from "../cli-color.js";
8
9
  import { EXIT } from "./shared.js";
9
10
  import { describeManifestActions, fuzzyResolveActions } from "./manifest.js";
10
11
  import {
@@ -19,6 +20,7 @@ import {
19
20
  import { getAdapterSurface } from "../adapters/surface.js";
20
21
  import { handleListExecutables } from "./list-executables.js";
21
22
  import { applyDeviceTargeting } from "./device-target.js";
23
+ import { coreDependencyBlock } from "./core-readiness.js";
22
24
  import {
23
25
  emitHealViolation,
24
26
  executeWithHealBounds,
@@ -96,6 +98,16 @@ async function handleCall(argv) {
96
98
  return EXIT.usage;
97
99
  }
98
100
  const resolvedAction = resolution.resolved;
101
+ const depsBlock = adapter === "core" && resolvedAction.startsWith("metamask.perps.") ? coreDependencyBlock(target) : null;
102
+ if (depsBlock) {
103
+ if (json) {
104
+ console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, status: "fail", exitCode: EXIT.usage, error: depsBlock }, null, 2));
105
+ } else {
106
+ console.error(`\u2717 call: ${depsBlock.message}`);
107
+ console.error(` Next: ${depsBlock.userAction}`);
108
+ }
109
+ return EXIT.usage;
110
+ }
99
111
  const recipe = synthesizeOneNodeRecipe(resolvedAction, args);
100
112
  const validation = await validateRecipeAdapterAware(recipe, manifest);
101
113
  if (validation.status === "invalid") {
@@ -157,8 +169,13 @@ async function handleCall(argv) {
157
169
  const rendered = callOutput !== void 0 ? `
158
170
  Result:
159
171
  ${formatCallOutput(callOutput)}` : "";
160
- console.log(`call ${resolvedAction}: ${result.status}${rendered}
161
- Artifacts: ${result.artifactManifestPath}`);
172
+ const out = (style, text) => color(style, text, { stream: process.stdout });
173
+ console.log(
174
+ `${out("label", "call")} ${out("cmd", resolvedAction)}: ${out(result.status === "pass" ? "ok" : "err", result.status)}${rendered ? `
175
+ ${out("label", "Result:")}
176
+ ${formatCallOutput(callOutput)}` : ""}
177
+ ${out("label", "Artifacts:")} ${out("path", result.artifactManifestPath)}`
178
+ );
162
179
  }
163
180
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
164
181
  }
@@ -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
- return process.env.ADB_SERIAL === device.id || process.env.ANDROID_SERIAL === device.id;
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
- setAndroidDeviceEnv(device, androidById.name);
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
- setIosDeviceEnv(device, iosById.name);
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
- const matched = byName[0];
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
- renderDeviceList
242
+ mobilePlatformPreference,
243
+ renderDeviceList,
244
+ scopedDevices
142
245
  };