@deeeed/metamask-harness 0.51.4 → 0.51.6
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 +29 -0
- package/README.md +25 -0
- package/adapters/extension/ensure-browser.sh +31 -7
- package/adapters/extension/launch-browser.cjs +154 -59
- package/adapters/extension/lib/macos-focus.cjs +207 -12
- package/adapters/extension/lib/validation-process-ownership.cjs +57 -2
- package/adapters/extension/live.sh +0 -2
- package/adapters/extension/stop-viewers.sh +1 -2
- package/dist/adapters/extension/validation-process-ownership.js +3 -2
- package/dist/adapters/slot-ports.js +65 -13
- package/dist/cli-commands.js +4 -1
- package/dist/cli.js +6 -0
- package/dist/command-contract.js +26 -1
- package/dist/commands/config.js +100 -0
- package/dist/commands/domain.js +56 -0
- package/dist/commands/help.js +2 -0
- package/dist/commands/launch/index.js +5 -1
- package/dist/commands/review.js +135 -0
- package/dist/mm-harness-cli.js +71 -3
- package/dist/review/knowledge.js +513 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { execFileSync } = require('node:child_process');
|
|
3
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
4
4
|
|
|
5
5
|
function commandHasExactProfile(command, profile) {
|
|
6
6
|
const expected = `--user-data-dir=${profile}`;
|
|
@@ -43,6 +43,61 @@ function delay(milliseconds) {
|
|
|
43
43
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// A process that already exited but has not been reaped yet (zombie) still
|
|
47
|
+
// answers `kill(pid, 0)`. The synchronous stop loop below never yields to the
|
|
48
|
+
// event loop, so a spawned child that dies immediately stays a zombie until the
|
|
49
|
+
// launcher returns; it must not count as a surviving browser.
|
|
50
|
+
function pidAlive(pid) {
|
|
51
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
52
|
+
try {
|
|
53
|
+
process.kill(pid, 0);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'ESRCH') return false;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
61
|
+
}).stdout.trim();
|
|
62
|
+
return state !== '' && !state.startsWith('Z');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function uniquePids(pids) {
|
|
66
|
+
return [...new Set(pids)].filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Reap every live process that still owns this profile, then refuse to continue
|
|
70
|
+
// if any survive. `open -n` otherwise starts a second headed Chrome on the same
|
|
71
|
+
// user-data-dir while the previous instance is still dying.
|
|
72
|
+
function stopProfileProcessesSync(profile, { extraPids = [], timeoutMs = 5_000, waitForAppearanceMs = 0 } = {}) {
|
|
73
|
+
const deadline = Date.now() + timeoutMs;
|
|
74
|
+
const appearUntil = Date.now() + Math.max(0, waitForAppearanceMs);
|
|
75
|
+
let ownersSince = 0;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
const pids = uniquePids([
|
|
78
|
+
...profileProcessPids(profile),
|
|
79
|
+
...extraPids.filter(pidAlive),
|
|
80
|
+
]);
|
|
81
|
+
if (pids.length === 0) {
|
|
82
|
+
if (Date.now() >= appearUntil) return;
|
|
83
|
+
} else {
|
|
84
|
+
if (ownersSince === 0) ownersSince = Date.now();
|
|
85
|
+
signalPids(pids, Date.now() - ownersSince >= 500 ? 'SIGKILL' : 'SIGTERM');
|
|
86
|
+
}
|
|
87
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
88
|
+
}
|
|
89
|
+
const remaining = uniquePids([
|
|
90
|
+
...profileProcessPids(profile),
|
|
91
|
+
...extraPids.filter(pidAlive),
|
|
92
|
+
]);
|
|
93
|
+
if (remaining.length > 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Owned Chrome processes survived stop (pid ${remaining.join(', ')}). ` +
|
|
96
|
+
'Next: mm-harness stop --adapter extension --target <checkout>',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 } = {}) {
|
|
47
102
|
const deadline = Date.now() + timeoutMs;
|
|
48
103
|
let quietSince = Date.now();
|
|
@@ -66,4 +121,4 @@ async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 }
|
|
|
66
121
|
throw new Error(`Extension validation profile did not remain quiescent for ${quietMs}ms.`);
|
|
67
122
|
}
|
|
68
123
|
|
|
69
|
-
module.exports = { profileProcessPids, stopProfileProcesses };
|
|
124
|
+
module.exports = { profileProcessPids, stopProfileProcesses, stopProfileProcessesSync };
|
|
@@ -228,7 +228,6 @@ NODE
|
|
|
228
228
|
quoted_check_infura="$(printf '%q' "$SCRIPT_DIR/check-infura-readiness.cjs")"
|
|
229
229
|
quoted_stamp_title="$(printf '%q' "$SCRIPT_DIR/stamp-runtime-title.cjs")"
|
|
230
230
|
quoted_chrome_launcher="$(printf '%q' "$SCRIPT_DIR/launch-browser.cjs")"
|
|
231
|
-
quoted_build_handoff="$(printf '%q' "$SCRIPT_DIR/build-handoff.cjs")"
|
|
232
231
|
quoted_artifact_state="$(printf '%q' "$SCRIPT_DIR/artifact-runtime-state.cjs")"
|
|
233
232
|
quoted_fixture_state="$(printf '%q' "$FIXTURE_STATE_ABS")"
|
|
234
233
|
quoted_fixture_validation="$(printf '%q' "$FIXTURE_VALIDATION_ABS")"
|
|
@@ -353,7 +352,6 @@ NODE
|
|
|
353
352
|
fi
|
|
354
353
|
prepare_parts+=("$chrome_launch_cmd")
|
|
355
354
|
prepare_parts+=("for i in {1..60}; do curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null 2>&1 && break; sleep 1; done; curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null")
|
|
356
|
-
prepare_parts+=("node ${quoted_build_handoff} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --cdp-port ${CDP_PORT}")
|
|
357
355
|
if [ -n "$WALLET_FIXTURE_ABS" ]; then
|
|
358
356
|
prepare_parts+=("bash ${quoted_seed_fixture} seed-cdp --target ${quoted_target} --fixture ${quoted_wallet_fixture} --state ${quoted_fixture_state} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --extension-id-file ${quoted_extension_id_file} --out ${quoted_fixture_validation}")
|
|
359
357
|
fi
|
|
@@ -29,7 +29,6 @@ done
|
|
|
29
29
|
unset _tv_src
|
|
30
30
|
|
|
31
31
|
runtime_dir="$TARGET/$(recipe_runtime_dir)"
|
|
32
|
-
console_log="$runtime_dir/extension-console.log"
|
|
33
32
|
set +e
|
|
34
33
|
|
|
35
34
|
if command -v tmux_viewer_close_marker >/dev/null 2>&1; then
|
|
@@ -48,7 +47,7 @@ ps -axo pid=,command= 2>/dev/null | while read -r pid command; do
|
|
|
48
47
|
*) continue ;;
|
|
49
48
|
esac
|
|
50
49
|
case "$command" in
|
|
51
|
-
*"
|
|
50
|
+
*"$runtime_dir/"*) kill "$pid" 2>/dev/null || true ;;
|
|
52
51
|
esac
|
|
53
52
|
done
|
|
54
53
|
|
|
@@ -3,8 +3,9 @@ const require2 = createRequire(import.meta.url);
|
|
|
3
3
|
const processOwnership = require2(
|
|
4
4
|
"../../../adapters/extension/lib/validation-process-ownership.cjs"
|
|
5
5
|
);
|
|
6
|
-
const { profileProcessPids, stopProfileProcesses } = processOwnership;
|
|
6
|
+
const { profileProcessPids, stopProfileProcesses, stopProfileProcessesSync } = processOwnership;
|
|
7
7
|
export {
|
|
8
8
|
profileProcessPids,
|
|
9
|
-
stopProfileProcesses
|
|
9
|
+
stopProfileProcesses,
|
|
10
|
+
stopProfileProcessesSync
|
|
10
11
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { recipeRuntimeDir } from "../paths.js";
|
|
5
6
|
import {
|
|
@@ -131,11 +132,60 @@ function stopExtensionWatcher(target) {
|
|
|
131
132
|
}
|
|
132
133
|
return ownedPids.length;
|
|
133
134
|
}
|
|
135
|
+
function commandFlagMatches(command, flag, value, mode) {
|
|
136
|
+
if (!path.isAbsolute(value)) return false;
|
|
137
|
+
const escapedFlag = escapeRegex(flag);
|
|
138
|
+
const escapedValue = escapeRegex(value);
|
|
139
|
+
const unquotedEnd = mode === "exact" ? "(?=\\s|$)" : "([^\\s]*)";
|
|
140
|
+
const quotedEnd = (quote) => mode === "exact" ? quote : `([^${quote}]*)`;
|
|
141
|
+
const patterns = [
|
|
142
|
+
new RegExp(`(?:^|\\s)${escapedFlag}=${escapedValue}${unquotedEnd}`, "u"),
|
|
143
|
+
new RegExp(`(?:^|\\s)${escapedFlag}="${escapedValue}${quotedEnd('"')}`, "u"),
|
|
144
|
+
new RegExp(`(?:^|\\s)${escapedFlag}='${escapedValue}${quotedEnd("'")}`, "u"),
|
|
145
|
+
new RegExp(`(?:^|\\s)${escapedFlag}\\s+${escapedValue}${unquotedEnd}`, "u")
|
|
146
|
+
];
|
|
147
|
+
for (const pattern of patterns) {
|
|
148
|
+
const match = pattern.exec(command);
|
|
149
|
+
if (!match) continue;
|
|
150
|
+
if (mode === "exact") return true;
|
|
151
|
+
const tail = match[1] ?? "";
|
|
152
|
+
if (!tail.split(/[\\/]/u).some((segment) => segment === "." || segment === "..")) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
function pathIsInsideDir(candidate, root) {
|
|
157
|
+
const resolved = path.resolve(candidate);
|
|
158
|
+
const base = path.resolve(root);
|
|
159
|
+
return resolved === base || resolved.startsWith(`${base}${path.sep}`);
|
|
160
|
+
}
|
|
161
|
+
function chromeUserDataDirOwnedByCheckout(command, runtimeAbs) {
|
|
162
|
+
return commandFlagMatches(command, "--user-data-dir", `${path.resolve(runtimeAbs)}${path.sep}`, "prefix");
|
|
163
|
+
}
|
|
164
|
+
function chromeUserDataDirIs(command, profileAbs) {
|
|
165
|
+
return commandFlagMatches(command, "--user-data-dir", path.resolve(profileAbs), "exact");
|
|
166
|
+
}
|
|
167
|
+
function commandLoadsCheckoutExtension(command, targetAbs) {
|
|
168
|
+
const prefix = `${path.resolve(targetAbs)}${path.sep}`;
|
|
169
|
+
return commandFlagMatches(command, "--load-extension", prefix, "prefix") || commandFlagMatches(command, "--disable-extensions-except", prefix, "prefix");
|
|
170
|
+
}
|
|
171
|
+
function isSharedOrDefaultBrowserProfile(profile) {
|
|
172
|
+
const resolved = path.resolve(profile);
|
|
173
|
+
const home = os.homedir();
|
|
174
|
+
const osDefaults = [
|
|
175
|
+
path.join(home, "Library/Application Support/Google/Chrome"),
|
|
176
|
+
path.join(home, "Library/Application Support/Chromium"),
|
|
177
|
+
path.join(home, "Library/Application Support/Microsoft Edge"),
|
|
178
|
+
path.join(home, ".config/google-chrome"),
|
|
179
|
+
path.join(home, ".config/chromium"),
|
|
180
|
+
path.join(home, ".config/microsoft-edge")
|
|
181
|
+
];
|
|
182
|
+
const underDefault = (dir) => resolved === dir || resolved.startsWith(`${dir}${path.sep}`);
|
|
183
|
+
return resolved === home || resolved.includes(`${path.sep}.chrome-farmslot`) || osDefaults.some(underDefault);
|
|
184
|
+
}
|
|
134
185
|
function stopExtensionRuntime(target) {
|
|
135
186
|
const resolved = path.resolve(target);
|
|
136
187
|
const runtimeAbs = path.join(resolved, recipeRuntimeDir());
|
|
137
|
-
const
|
|
138
|
-
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));
|
|
188
|
+
const extraProfile = process.env.CHROME_USER_DATA_DIR ? path.resolve(process.env.CHROME_USER_DATA_DIR) : null;
|
|
139
189
|
let signalled = stopExtensionWatcher(resolved);
|
|
140
190
|
const ownedBrowserPids = /* @__PURE__ */ new Set();
|
|
141
191
|
try {
|
|
@@ -144,7 +194,15 @@ function stopExtensionRuntime(target) {
|
|
|
144
194
|
const match = /^\s*(\d+)\s+(.*)$/u.exec(line);
|
|
145
195
|
if (!match) continue;
|
|
146
196
|
const pid = Number(match[1]);
|
|
147
|
-
if (pid
|
|
197
|
+
if (pid === process.pid) continue;
|
|
198
|
+
if (chromeUserDataDirOwnedByCheckout(match[2], runtimeAbs)) {
|
|
199
|
+
ownedBrowserPids.add(pid);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!extraProfile || pathIsInsideDir(extraProfile, runtimeAbs) || isSharedOrDefaultBrowserProfile(extraProfile)) continue;
|
|
203
|
+
if (chromeUserDataDirIs(match[2], extraProfile) && commandLoadsCheckoutExtension(match[2], resolved)) {
|
|
204
|
+
ownedBrowserPids.add(pid);
|
|
205
|
+
}
|
|
148
206
|
}
|
|
149
207
|
} catch {
|
|
150
208
|
}
|
|
@@ -203,22 +261,16 @@ function processAlive(pid) {
|
|
|
203
261
|
return error.code === "EPERM";
|
|
204
262
|
}
|
|
205
263
|
}
|
|
206
|
-
function commandHasExactArg(command, flag, value) {
|
|
207
|
-
const escapedFlag = escapeRegex(flag);
|
|
208
|
-
const escapedValue = escapeRegex(value);
|
|
209
|
-
return [
|
|
210
|
-
new RegExp(`(?:^|\\s)${escapedFlag}=${escapedValue}(?=\\s|$)`, "u"),
|
|
211
|
-
new RegExp(`(?:^|\\s)${escapedFlag}="${escapedValue}"(?=\\s|$)`, "u"),
|
|
212
|
-
new RegExp(`(?:^|\\s)${escapedFlag}='${escapedValue}'(?=\\s|$)`, "u"),
|
|
213
|
-
new RegExp(`(?:^|\\s)${escapedFlag}\\s+${escapedValue}(?=\\s|$)`, "u")
|
|
214
|
-
].some((pattern) => pattern.test(command));
|
|
215
|
-
}
|
|
216
264
|
function escapeRegex(value) {
|
|
217
265
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
218
266
|
}
|
|
219
267
|
export {
|
|
220
268
|
applyKVLines,
|
|
269
|
+
chromeUserDataDirIs,
|
|
270
|
+
chromeUserDataDirOwnedByCheckout,
|
|
221
271
|
isExtensionWatcherLive,
|
|
272
|
+
isSharedOrDefaultBrowserProfile,
|
|
273
|
+
pathIsInsideDir,
|
|
222
274
|
resolveExtensionSlotPorts,
|
|
223
275
|
resolveMobileSlotPorts,
|
|
224
276
|
stopExtensionRuntime,
|
package/dist/cli-commands.js
CHANGED
|
@@ -8,7 +8,10 @@ const SPEC = {
|
|
|
8
8
|
{ name: "help", aliases: ["-h", "--help"], desc: "Show usage" }
|
|
9
9
|
],
|
|
10
10
|
shared: [
|
|
11
|
-
{ name: "help", desc: "Load version-matched recipe guidance", flags: ["--json", "--adapter", "--target"] },
|
|
11
|
+
{ name: "help", desc: "Load version-matched recipe guidance (help review composes the review guide)", args: ["review"], flags: ["--json", "--adapter", "--target", "--domain"] },
|
|
12
|
+
{ name: "review", desc: "Materialize a review checklist composed from the base review and a team library", args: ["checklist"], flags: ["--domain", "--since", "--base", "--out", "--adapter", "--target", "--json"] },
|
|
13
|
+
{ name: "domain", desc: "Which team library owns the change (declared value, else owned-paths.json)", flags: ["--domain", "--base", "--adapter", "--target", "--json"] },
|
|
14
|
+
{ name: "config", desc: "Per-engineer locations: libraries.<name>, references.<adapter>", args: ["list", "path", "get", "set", "unset"], flags: ["--json"] },
|
|
12
15
|
{ name: "tutorial", desc: "Open the visual recipe tutorial", flags: ["--json", "--no-open"] },
|
|
13
16
|
{ name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
|
|
14
17
|
{ name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
|
package/dist/cli.js
CHANGED
|
@@ -25,6 +25,9 @@ import { handleExecutionTemplate } from "./commands/execution-template.js";
|
|
|
25
25
|
import { handlePrepare } from "./commands/prepare.js";
|
|
26
26
|
import { handleTaskInit } from "./commands/task-init.js";
|
|
27
27
|
import { handleLast } from "./commands/last.js";
|
|
28
|
+
import { handleReview } from "./commands/review.js";
|
|
29
|
+
import { handleDomain } from "./commands/domain.js";
|
|
30
|
+
import { handleConfig } from "./commands/config.js";
|
|
28
31
|
import { parseArgs, targetPath } from "./commands/parse-args.js";
|
|
29
32
|
const COMMANDS = {
|
|
30
33
|
actions: handleActions,
|
|
@@ -132,6 +135,9 @@ async function main(argv) {
|
|
|
132
135
|
if (command === "task") return handleTaskInit(argv.slice(1));
|
|
133
136
|
if (command === "prepare") return handlePrepare(argv.slice(1));
|
|
134
137
|
if (command === "last") return handleLast(parseArgs(argv.slice(1), command));
|
|
138
|
+
if (command === "review") return handleReview(argv.slice(1));
|
|
139
|
+
if (command === "domain") return handleDomain(argv.slice(1));
|
|
140
|
+
if (command === "config") return handleConfig(argv.slice(1));
|
|
135
141
|
const handler = COMMANDS[command];
|
|
136
142
|
if (!handler) throw new Error(`Unknown command: ${command}`);
|
|
137
143
|
return handler(parseArgs(argv.slice(1), command));
|
package/dist/command-contract.js
CHANGED
|
@@ -58,7 +58,32 @@ const REMOVED_OPTION_REPLACEMENTS = {
|
|
|
58
58
|
};
|
|
59
59
|
const PUBLIC_COMMAND_CONTRACTS = {
|
|
60
60
|
help: {
|
|
61
|
-
options: options(HELP, JSON, TARGET, ADAPTER)
|
|
61
|
+
options: options(HELP, JSON, TARGET, ADAPTER, { "--domain": value() }),
|
|
62
|
+
positionals: [{ label: "topic", choices: ["review"] }]
|
|
63
|
+
},
|
|
64
|
+
review: {
|
|
65
|
+
options: options(HELP, JSON, TARGET, ADAPTER, {
|
|
66
|
+
"--domain": value(),
|
|
67
|
+
"--since": value(),
|
|
68
|
+
"--base": value(),
|
|
69
|
+
"--out": value()
|
|
70
|
+
}),
|
|
71
|
+
positionals: [{ label: "action", choices: ["checklist"] }],
|
|
72
|
+
minimumPositionals: 1
|
|
73
|
+
},
|
|
74
|
+
domain: {
|
|
75
|
+
options: options(HELP, JSON, TARGET, ADAPTER, {
|
|
76
|
+
"--domain": value(),
|
|
77
|
+
"--base": value()
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
config: {
|
|
81
|
+
options: options(HELP, JSON),
|
|
82
|
+
positionals: [
|
|
83
|
+
{ label: "action", choices: ["list", "path", "get", "set", "unset"] },
|
|
84
|
+
{ label: "key" },
|
|
85
|
+
{ label: "value" }
|
|
86
|
+
]
|
|
62
87
|
},
|
|
63
88
|
tutorial: {
|
|
64
89
|
options: options(HELP, JSON, NO_OPEN)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
configPath,
|
|
4
|
+
readConfig,
|
|
5
|
+
REVIEW_ADAPTERS,
|
|
6
|
+
writeConfig
|
|
7
|
+
} from "../review/knowledge.js";
|
|
8
|
+
import { optionFlag, parseArgs, usageError } from "./parse-args.js";
|
|
9
|
+
const USAGE = "Usage: mm-harness config <list|path|get <key>|set <key> <path>|unset <key>> [--json]\n keys: libraries.<name>, references.<mobile|extension|core>";
|
|
10
|
+
function parseKey(raw) {
|
|
11
|
+
if (!raw) throw usageError(USAGE);
|
|
12
|
+
const dot = raw.indexOf(".");
|
|
13
|
+
const section = dot === -1 ? raw : raw.slice(0, dot);
|
|
14
|
+
const name = dot === -1 ? "" : raw.slice(dot + 1);
|
|
15
|
+
if (!name) throw usageError(`Config key must be libraries.<name> or references.<adapter> (got ${raw}).`);
|
|
16
|
+
if (section === "libraries") return { section, name };
|
|
17
|
+
if (section === "references") {
|
|
18
|
+
if (!REVIEW_ADAPTERS.includes(name)) {
|
|
19
|
+
throw usageError(`references.<adapter> must be one of ${REVIEW_ADAPTERS.join(", ")} (got ${name}).`);
|
|
20
|
+
}
|
|
21
|
+
return { section, name };
|
|
22
|
+
}
|
|
23
|
+
throw usageError(`Config key must start with libraries. or references. (got ${raw}).`);
|
|
24
|
+
}
|
|
25
|
+
function getValue(config, key) {
|
|
26
|
+
return key.section === "libraries" ? config.libraries?.[key.name] : config.references?.[key.name];
|
|
27
|
+
}
|
|
28
|
+
function setValue(config, key, value) {
|
|
29
|
+
const section = config[key.section] ?? {};
|
|
30
|
+
if (value === void 0) delete section[key.name];
|
|
31
|
+
else section[key.name] = value;
|
|
32
|
+
if (Object.keys(section).length === 0) delete config[key.section];
|
|
33
|
+
else config[key.section] = section;
|
|
34
|
+
}
|
|
35
|
+
async function handleConfig(argv) {
|
|
36
|
+
const parsed = parseArgs(argv, "config");
|
|
37
|
+
const json = optionFlag(parsed.options, "json");
|
|
38
|
+
const [action, rawKey, rawValue] = parsed.positional;
|
|
39
|
+
const expectedPositionals = { path: 1, list: 1, get: 2, set: 3, unset: 2 }[action ?? "list"];
|
|
40
|
+
if (expectedPositionals !== void 0 && parsed.positional.length > expectedPositionals) {
|
|
41
|
+
throw usageError(`Unexpected argument "${parsed.positional[expectedPositionals]}". ${USAGE}`);
|
|
42
|
+
}
|
|
43
|
+
const file = configPath();
|
|
44
|
+
const config = readConfig(file);
|
|
45
|
+
if (action === "path") {
|
|
46
|
+
process.stdout.write(json ? `${JSON.stringify({ command: "config", path: file })}
|
|
47
|
+
` : `${file}
|
|
48
|
+
`);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
if (action === "list" || action === void 0) {
|
|
52
|
+
if (json) {
|
|
53
|
+
process.stdout.write(`${JSON.stringify({ command: "config", path: file, ...config }, null, 2)}
|
|
54
|
+
`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
const lines = [
|
|
58
|
+
...Object.entries(config.libraries ?? {}).map(([name, root]) => `libraries.${name}=${root}`),
|
|
59
|
+
...Object.entries(config.references ?? {}).map(([name, root]) => `references.${name}=${root}`)
|
|
60
|
+
];
|
|
61
|
+
process.stdout.write(lines.length > 0 ? `${lines.join("\n")}
|
|
62
|
+
` : `(empty) ${file}
|
|
63
|
+
`);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
if (action === "get") {
|
|
67
|
+
const value = getValue(config, parseKey(rawKey));
|
|
68
|
+
if (json) {
|
|
69
|
+
process.stdout.write(`${JSON.stringify({ command: "config", key: rawKey, value: value ?? null })}
|
|
70
|
+
`);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (value === void 0) return 1;
|
|
74
|
+
process.stdout.write(`${value}
|
|
75
|
+
`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
if (action === "set") {
|
|
79
|
+
const key = parseKey(rawKey);
|
|
80
|
+
if (!rawValue) throw usageError(`mm-harness config set ${rawKey} <path>`);
|
|
81
|
+
setValue(config, key, path.resolve(rawValue));
|
|
82
|
+
writeConfig(file, config);
|
|
83
|
+
process.stdout.write(json ? `${JSON.stringify({ command: "config", key: rawKey, value: path.resolve(rawValue), path: file })}
|
|
84
|
+
` : `${rawKey}=${path.resolve(rawValue)}
|
|
85
|
+
`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (action === "unset") {
|
|
89
|
+
setValue(config, parseKey(rawKey), void 0);
|
|
90
|
+
writeConfig(file, config);
|
|
91
|
+
process.stdout.write(json ? `${JSON.stringify({ command: "config", key: rawKey, value: null, path: file })}
|
|
92
|
+
` : `${rawKey} removed
|
|
93
|
+
`);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
throw usageError(USAGE);
|
|
97
|
+
}
|
|
98
|
+
export {
|
|
99
|
+
handleConfig
|
|
100
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
changedFiles,
|
|
3
|
+
classifyDomain,
|
|
4
|
+
configPath,
|
|
5
|
+
discoverLibraries,
|
|
6
|
+
readConfig
|
|
7
|
+
} from "../review/knowledge.js";
|
|
8
|
+
import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
|
|
9
|
+
import { adapterFromOptions } from "./review.js";
|
|
10
|
+
async function handleDomain(argv) {
|
|
11
|
+
const parsed = parseArgs(argv, "domain");
|
|
12
|
+
const target = targetPath(parsed.options);
|
|
13
|
+
const json = optionFlag(parsed.options, "json");
|
|
14
|
+
const declared = optionString(parsed.options, "domain") ?? process.env.MM_HARNESS_DOMAIN;
|
|
15
|
+
const detected = adapterFromOptions(parsed.options, target);
|
|
16
|
+
const adapter = detected === "unscoped" ? void 0 : detected;
|
|
17
|
+
if (declared) {
|
|
18
|
+
emit(json, { domain: declared, source: "declared", adapter: adapter ?? null, target });
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
if (!adapter) {
|
|
22
|
+
throw usageError("No MetaMask checkout detected: pass --adapter <mobile|extension|core> or --domain <name>.");
|
|
23
|
+
}
|
|
24
|
+
const config = readConfig(configPath());
|
|
25
|
+
const libraries = discoverLibraries({ target, config });
|
|
26
|
+
const { base, files } = changedFiles(target, optionString(parsed.options, "base"));
|
|
27
|
+
const matches = classifyDomain(files, adapter, libraries);
|
|
28
|
+
const best = matches[0];
|
|
29
|
+
emit(json, {
|
|
30
|
+
domain: best?.domain ?? null,
|
|
31
|
+
source: best ? "owned-paths" : "none",
|
|
32
|
+
adapter,
|
|
33
|
+
target,
|
|
34
|
+
base,
|
|
35
|
+
changedFiles: files.length,
|
|
36
|
+
libraries: libraries.map((library) => ({ name: library.name, root: library.root, source: library.source, revision: library.revision ?? null })),
|
|
37
|
+
matches: matches.map((match) => ({ domain: match.domain, library: match.library.root, matched: match.matched }))
|
|
38
|
+
});
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
function emit(json, payload) {
|
|
42
|
+
if (json) {
|
|
43
|
+
process.stdout.write(`${JSON.stringify({ command: "domain", ...payload }, null, 2)}
|
|
44
|
+
`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(`${payload.domain ?? "none"}
|
|
48
|
+
`);
|
|
49
|
+
const best = payload.matches?.[0];
|
|
50
|
+
const detail = payload.source === "declared" ? "declared via --domain or MM_HARNESS_DOMAIN" : best ? `owned-paths match in ${best.library} (${best.matched.length} of ${payload.changedFiles} changed files)` : `no library owns the ${payload.changedFiles} changed files (base ${payload.base}; libraries scanned: ${payload.libraries?.length ?? 0})`;
|
|
51
|
+
process.stderr.write(`domain: ${detail}
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
handleDomain
|
|
56
|
+
};
|
package/dist/commands/help.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { color } from "../cli-color.js";
|
|
4
4
|
import { detectAdapter } from "../harness.js";
|
|
5
5
|
import { optionString, parseArgs, targetPath } from "./parse-args.js";
|
|
6
|
+
import { handleHelpReview } from "./review.js";
|
|
6
7
|
const UNSCOPED_NEXT = "cd into a MetaMask checkout or pass --adapter <mobile|extension|core>";
|
|
7
8
|
function validHelpLine(value) {
|
|
8
9
|
return typeof value === "string" || Boolean(value && typeof value.text === "string" && Array.isArray(value.adapters) && value.adapters.length > 0 && value.adapters.every((adapter) => ["mobile", "extension", "core"].includes(adapter)));
|
|
@@ -70,6 +71,7 @@ function renderRecipeHelp(topic, harnessVersion, adapter) {
|
|
|
70
71
|
}
|
|
71
72
|
function handleHelp(argv, context) {
|
|
72
73
|
const parsed = parseArgs(argv, "help");
|
|
74
|
+
if (parsed.positional[0] === "review") return handleHelpReview(argv, context.harnessVersion);
|
|
73
75
|
const target = targetPath(parsed.options);
|
|
74
76
|
const adapter = optionString(parsed.options, "adapter") ?? detectAdapter(target) ?? "unscoped";
|
|
75
77
|
const topic = loadRecipeHelp(context.packageRoot);
|
|
@@ -422,6 +422,10 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
422
422
|
state.attemptedRecoveries.push(recoveryCode);
|
|
423
423
|
stream.phase("recover");
|
|
424
424
|
const recoveryTier = restartMobileApp && tier === "build" ? "quick" : tier;
|
|
425
|
+
if (!jsonOutput) {
|
|
426
|
+
process.stderr.write(`launch: recovering (${recoveryCode}) with the ${recoveryTier} tier
|
|
427
|
+
`);
|
|
428
|
+
}
|
|
425
429
|
attempt = await executeComposition(
|
|
426
430
|
adapter,
|
|
427
431
|
mobileTarget,
|
|
@@ -469,7 +473,7 @@ function ambiguousConfiguredIosSimulator(output) {
|
|
|
469
473
|
return /open-device: iOS simulator target ("[^"]+") is ambiguous/u.exec(output)?.[1];
|
|
470
474
|
}
|
|
471
475
|
function mobileBridgeTargetMissing(output) {
|
|
472
|
-
return /no bridge target matched/iu.test(output);
|
|
476
|
+
return /no bridge target matched|wait-for-bridge: readiness deadline expired/iu.test(output);
|
|
473
477
|
}
|
|
474
478
|
function mobileProvisionCommand(target, mobileTarget) {
|
|
475
479
|
const platform = mobileTarget === "android" ? "android" : "ios";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectAdapter } from "../harness.js";
|
|
4
|
+
import { runnerDir } from "../paths.js";
|
|
5
|
+
import {
|
|
6
|
+
configPath,
|
|
7
|
+
loadDomainKnowledge,
|
|
8
|
+
parityAdapter,
|
|
9
|
+
readConfig,
|
|
10
|
+
referenceHint,
|
|
11
|
+
renderReviewChecklist,
|
|
12
|
+
renderReviewHelp,
|
|
13
|
+
resolveLibrary,
|
|
14
|
+
resolveReference,
|
|
15
|
+
defaultBaseRef
|
|
16
|
+
} from "../review/knowledge.js";
|
|
17
|
+
import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
|
|
18
|
+
const EXIT_OK = 0;
|
|
19
|
+
let cachedHarnessVersion;
|
|
20
|
+
function harnessVersion() {
|
|
21
|
+
if (cachedHarnessVersion === void 0) {
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, "package.json"), "utf8"));
|
|
23
|
+
cachedHarnessVersion = pkg.version ?? "unknown";
|
|
24
|
+
}
|
|
25
|
+
return cachedHarnessVersion;
|
|
26
|
+
}
|
|
27
|
+
function adapterFromOptions(options, target) {
|
|
28
|
+
const raw = optionString(options, "adapter") ?? optionString(options, "platform");
|
|
29
|
+
if (raw !== void 0) {
|
|
30
|
+
if (raw !== "mobile" && raw !== "extension" && raw !== "core") throw usageError(`--adapter must be mobile, extension, or core (got ${raw}).`);
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
return detectAdapter(target) ?? "unscoped";
|
|
34
|
+
}
|
|
35
|
+
function resolveReviewContext(options, { fetch = true } = {}) {
|
|
36
|
+
const target = targetPath(options);
|
|
37
|
+
const adapter = adapterFromOptions(options, target);
|
|
38
|
+
const domain = optionString(options, "domain") ?? process.env.MM_HARNESS_DOMAIN;
|
|
39
|
+
const config = readConfig(configPath());
|
|
40
|
+
const context = { target, adapter, ...domain ? { domain } : {} };
|
|
41
|
+
if (domain) {
|
|
42
|
+
const library = resolveLibrary(domain, { target, config, fetch });
|
|
43
|
+
if (library) context.knowledge = loadDomainKnowledge(library, adapter === "unscoped" ? void 0 : adapter);
|
|
44
|
+
else if (fetch) throw usageError(`No library found for domain "${domain}". Set RECIPE_LIBRARY_PATH="${domain}=<path>" or run: mm-harness config set libraries.${domain} <path>`);
|
|
45
|
+
}
|
|
46
|
+
const parity = parityAdapter(adapter);
|
|
47
|
+
if (parity) {
|
|
48
|
+
const reference = resolveReference(parity, { target, config });
|
|
49
|
+
if (reference) context.reference = reference;
|
|
50
|
+
else context.referenceMissing = `no ${parity} checkout found (${referenceHint(parity)})`;
|
|
51
|
+
}
|
|
52
|
+
return context;
|
|
53
|
+
}
|
|
54
|
+
function handleHelpReview(argv, harnessVersion2) {
|
|
55
|
+
const parsed = parseArgs(argv, "help");
|
|
56
|
+
const context = resolveReviewContext(parsed.options, { fetch: false });
|
|
57
|
+
const input = {
|
|
58
|
+
adapter: context.adapter,
|
|
59
|
+
harnessVersion: harnessVersion2,
|
|
60
|
+
...context.domain ? { domain: context.domain } : {},
|
|
61
|
+
...context.knowledge ? { knowledge: context.knowledge } : {},
|
|
62
|
+
...context.reference ? { reference: context.reference } : {},
|
|
63
|
+
...context.referenceMissing ? { referenceMissing: context.referenceMissing } : {}
|
|
64
|
+
};
|
|
65
|
+
if (optionFlag(parsed.options, "json")) {
|
|
66
|
+
process.stdout.write(`${JSON.stringify({ command: "help", topic: "review", ...input }, null, 2)}
|
|
67
|
+
`);
|
|
68
|
+
} else {
|
|
69
|
+
process.stdout.write(renderReviewHelp(input));
|
|
70
|
+
}
|
|
71
|
+
return EXIT_OK;
|
|
72
|
+
}
|
|
73
|
+
async function handleReview(argv, version = harnessVersion()) {
|
|
74
|
+
const parsed = parseArgs(argv, "review");
|
|
75
|
+
const subcommand = parsed.positional[0];
|
|
76
|
+
if (subcommand !== "checklist") {
|
|
77
|
+
throw usageError("Usage: mm-harness review checklist [--domain <name>] [--since <sha>] [--base <ref>] [--out <file>] [--json]");
|
|
78
|
+
}
|
|
79
|
+
const context = resolveReviewContext(parsed.options);
|
|
80
|
+
const since = optionString(parsed.options, "since");
|
|
81
|
+
const base = optionString(parsed.options, "base") ?? defaultBaseRef(context.target);
|
|
82
|
+
const checklist = renderReviewChecklist({
|
|
83
|
+
adapter: context.adapter,
|
|
84
|
+
target: context.target,
|
|
85
|
+
base,
|
|
86
|
+
harnessVersion: version,
|
|
87
|
+
...since ? { since } : {},
|
|
88
|
+
...context.domain ? { domain: context.domain } : {},
|
|
89
|
+
...context.knowledge ? { knowledge: context.knowledge } : {},
|
|
90
|
+
...context.reference ? { reference: context.reference } : {},
|
|
91
|
+
...context.referenceMissing ? { referenceMissing: context.referenceMissing } : {}
|
|
92
|
+
});
|
|
93
|
+
const out = optionString(parsed.options, "out");
|
|
94
|
+
if (out) {
|
|
95
|
+
const file = path.resolve(out);
|
|
96
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
97
|
+
fs.writeFileSync(file, checklist);
|
|
98
|
+
}
|
|
99
|
+
if (optionFlag(parsed.options, "json")) {
|
|
100
|
+
process.stdout.write(
|
|
101
|
+
`${JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
command: "review",
|
|
104
|
+
subcommand: "checklist",
|
|
105
|
+
target: context.target,
|
|
106
|
+
adapter: context.adapter,
|
|
107
|
+
domain: context.domain ?? null,
|
|
108
|
+
library: context.knowledge?.library ?? null,
|
|
109
|
+
reference: context.reference ?? null,
|
|
110
|
+
referenceMissing: context.referenceMissing ?? null,
|
|
111
|
+
base,
|
|
112
|
+
since: since ?? null,
|
|
113
|
+
out: out ? path.resolve(out) : null,
|
|
114
|
+
checklist
|
|
115
|
+
},
|
|
116
|
+
null,
|
|
117
|
+
2
|
|
118
|
+
)}
|
|
119
|
+
`
|
|
120
|
+
);
|
|
121
|
+
} else if (out) {
|
|
122
|
+
process.stdout.write(`review checklist: ${path.resolve(out)}
|
|
123
|
+
`);
|
|
124
|
+
} else {
|
|
125
|
+
process.stdout.write(checklist);
|
|
126
|
+
}
|
|
127
|
+
return EXIT_OK;
|
|
128
|
+
}
|
|
129
|
+
export {
|
|
130
|
+
adapterFromOptions,
|
|
131
|
+
handleHelpReview,
|
|
132
|
+
handleReview,
|
|
133
|
+
harnessVersion,
|
|
134
|
+
resolveReviewContext
|
|
135
|
+
};
|