@deeeed/metamask-harness 0.51.5 → 0.51.7

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.
@@ -1,32 +1,236 @@
1
1
  'use strict';
2
2
 
3
- const { execFileSync } = require('node:child_process');
3
+ const { execFileSync, spawn } = require('node:child_process');
4
+ const fs = require('node:fs');
4
5
 
5
- function captureMacFrontmostProcess() {
6
+ // Window activation is not sub-20ms; 80ms keeps the hold responsive while
7
+ // spawning far fewer lsappinfo/open subprocesses per second.
8
+ const HOLD_INTERVAL_MS = 80;
9
+ // Upper bound for one Launch Services query or `open -a`. A short bound made a
10
+ // loaded Mac time out the first query, which silently disabled the whole hold.
11
+ const LS_TIMEOUT_MS = 2000;
12
+ // Hard cap on a hold whose parent is alive but never released it.
13
+ const HOLD_MAX_MS = 120_000;
14
+ const toolEnv = () => ({ ...process.env });
15
+
16
+ function captureMacFrontmost() {
6
17
  if (process.platform !== 'darwin') return null;
7
18
  try {
8
- const value = execFileSync('osascript', [
9
- '-e',
10
- 'tell application "System Events" to get unix id of first application process whose frontmost is true',
11
- ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1_000 }).trim();
12
- const pid = Number.parseInt(value, 10);
19
+ const asn = execFileSync('lsappinfo', ['front'], {
20
+ encoding: 'utf8',
21
+ env: toolEnv(),
22
+ stdio: ['ignore', 'pipe', 'ignore'],
23
+ timeout: LS_TIMEOUT_MS,
24
+ }).trim();
25
+ if (!asn) return null;
26
+ return parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
27
+ encoding: 'utf8',
28
+ env: toolEnv(),
29
+ stdio: ['ignore', 'pipe', 'ignore'],
30
+ timeout: LS_TIMEOUT_MS,
31
+ }));
32
+ } catch {
33
+ // Best effort: lsappinfo missing, timed out, or the app exited; no focus to preserve.
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function captureMacFrontmostProcess() {
39
+ return captureMacFrontmost()?.pid ?? null;
40
+ }
41
+
42
+ function parseLsappinfo(info) {
43
+ const pid = Number.parseInt((info.match(/\bpid\s*=\s*(\d+)/u) || info.match(/"pid"=(\d+)/u) || [])[1], 10);
44
+ const bundlePath = (info.match(/bundle path="([^"]+)"/u) || info.match(/"LSBundlePath"="([^"]+)"/u) || [])[1];
45
+ const name = (info.match(/^"([^"]+)"/mu) || info.match(/"LSDisplayName"="([^"]+)"/u) || [])[1];
46
+ if (!Number.isInteger(pid) || pid <= 0 || !bundlePath) return null;
47
+ return { pid, bundlePath, name: name || bundlePath };
48
+ }
49
+
50
+ function currentFrontmostPid() {
51
+ try {
52
+ const asn = execFileSync('lsappinfo', ['front'], {
53
+ encoding: 'utf8',
54
+ env: toolEnv(),
55
+ stdio: ['ignore', 'pipe', 'ignore'],
56
+ timeout: LS_TIMEOUT_MS,
57
+ }).trim();
58
+ if (!asn) return null;
59
+ const value = execFileSync('lsappinfo', ['info', '-only', 'pid', asn], {
60
+ encoding: 'utf8',
61
+ env: toolEnv(),
62
+ stdio: ['ignore', 'pipe', 'ignore'],
63
+ timeout: LS_TIMEOUT_MS,
64
+ }).trim();
65
+ const pid = Number.parseInt((value.match(/"pid"=(\d+)/u) || [])[1], 10);
13
66
  return Number.isInteger(pid) && pid > 0 ? pid : null;
14
67
  } catch {
68
+ // Best effort: lsappinfo unavailable or timed out; callers treat null as unknown.
15
69
  return null;
16
70
  }
17
71
  }
18
72
 
73
+ function restoreMacFrontmost(target) {
74
+ if (process.platform !== 'darwin' || !target?.bundlePath) return false;
75
+ try {
76
+ execFileSync('open', ['-a', target.bundlePath], {
77
+ env: toolEnv(),
78
+ stdio: 'ignore',
79
+ timeout: LS_TIMEOUT_MS,
80
+ });
81
+ return true;
82
+ } catch {
83
+ // Best effort: `open -a` failed or timed out; the operator keeps whatever is in front.
84
+ return false;
85
+ }
86
+ }
87
+
19
88
  function restoreMacFrontmostProcess(pid) {
20
89
  if (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 0) return false;
21
90
  try {
22
- execFileSync('osascript', [
23
- '-e',
24
- `tell application "System Events" to set frontmost of first application process whose unix id is ${pid} to true`,
25
- ], { stdio: 'ignore', timeout: 1_000 });
91
+ const asn = execFileSync('lsappinfo', ['find', `pid=${pid}`], {
92
+ encoding: 'utf8',
93
+ env: toolEnv(),
94
+ stdio: ['ignore', 'pipe', 'ignore'],
95
+ timeout: LS_TIMEOUT_MS,
96
+ }).trim();
97
+ if (!asn) return false;
98
+ return restoreMacFrontmost(parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
99
+ encoding: 'utf8',
100
+ env: toolEnv(),
101
+ stdio: ['ignore', 'pipe', 'ignore'],
102
+ timeout: LS_TIMEOUT_MS,
103
+ })));
104
+ } catch {
105
+ // Best effort: lsappinfo unavailable, timed out, or the pid has no Launch Services entry; nothing to restore.
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // Bring `target` back in front of the launched browser. With `launchedBundlePath`
111
+ // given, any other frontmost app is an intentional operator switch and is kept.
112
+ function preserveMacFrontmost(target, launchedBundlePath) {
113
+ if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return false;
114
+ if (process.platform !== 'darwin' || !target?.bundlePath || !Number.isInteger(target.pid) || target.pid <= 0) {
115
+ return false;
116
+ }
117
+ const front = captureMacFrontmost();
118
+ if (front && front.pid === target.pid) return true;
119
+ if (front && launchedBundlePath && !sameBundle(front.bundlePath, launchedBundlePath)) return false;
120
+ return restoreMacFrontmost(target);
121
+ }
122
+
123
+ function preserveMacFrontmostProcess(pid) {
124
+ if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return false;
125
+ if (!Number.isInteger(pid) || pid <= 0) return false;
126
+ const current = currentFrontmostPid();
127
+ if (current === pid) return true;
128
+ return restoreMacFrontmostProcess(pid);
129
+ }
130
+
131
+ function macBackgroundOpenArgs(application, chromeArgs) {
132
+ return ['-g', '-n', '-a', application, '--args', ...chromeArgs];
133
+ }
134
+
135
+ // Keep the previously focused app in front while `launchedBundlePath` starts.
136
+ // The hold only counters the launched browser: the moment any other app is
137
+ // brought to the front, the operator switched on purpose and the hold ends.
138
+ function startMacFocusHold(targetOrPid, launchedBundlePath) {
139
+ if (process.env.MM_HARNESS_FOCUS_BROWSER === '1') return null;
140
+ if (process.platform !== 'darwin') return null;
141
+ const target = typeof targetOrPid === 'number'
142
+ ? { pid: targetOrPid, bundlePath: bundlePathForPid(targetOrPid) }
143
+ : targetOrPid;
144
+ if (!target?.bundlePath || !Number.isInteger(target.pid) || target.pid <= 0) return null;
145
+ return spawn(process.execPath, [
146
+ __filename,
147
+ '--hold',
148
+ String(target.pid),
149
+ target.bundlePath,
150
+ String(process.pid),
151
+ launchedBundlePath || '',
152
+ ], { stdio: 'ignore', env: toolEnv() });
153
+ }
154
+
155
+ function sameBundle(left, right) {
156
+ if (!left || !right) return false;
157
+ const real = (value) => {
158
+ try {
159
+ return fs.realpathSync(value);
160
+ } catch {
161
+ // A path that cannot be resolved is compared as given.
162
+ return value;
163
+ }
164
+ };
165
+ return real(left) === real(right);
166
+ }
167
+
168
+ function bundlePathForPid(pid) {
169
+ try {
170
+ const asn = execFileSync('lsappinfo', ['find', `pid=${pid}`], {
171
+ encoding: 'utf8',
172
+ env: toolEnv(),
173
+ stdio: ['ignore', 'pipe', 'ignore'],
174
+ timeout: LS_TIMEOUT_MS,
175
+ }).trim();
176
+ return parseLsappinfo(execFileSync('lsappinfo', ['info', asn], {
177
+ encoding: 'utf8',
178
+ env: toolEnv(),
179
+ stdio: ['ignore', 'pipe', 'ignore'],
180
+ timeout: LS_TIMEOUT_MS,
181
+ }))?.bundlePath ?? null;
182
+ } catch {
183
+ // Best effort: lsappinfo unavailable, timed out, or the pid has no Launch Services entry; the hold is skipped.
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function stopMacFocusHold(child) {
189
+ if (!child || !Number.isInteger(child.pid) || child.pid <= 0) return;
190
+ try {
191
+ process.kill(child.pid, 'SIGTERM');
192
+ } catch (error) {
193
+ if (error.code !== 'ESRCH') throw error;
194
+ }
195
+ }
196
+
197
+ function parentAlive(pid) {
198
+ if (!Number.isInteger(pid) || pid <= 0) return false;
199
+ try {
200
+ process.kill(pid, 0);
26
201
  return true;
27
202
  } catch {
203
+ // ESRCH or EPERM: the parent is gone or not ours; either way the hold must end.
28
204
  return false;
29
205
  }
30
206
  }
31
207
 
32
- module.exports = { captureMacFrontmostProcess, restoreMacFrontmostProcess };
208
+ if (require.main === module && process.argv[2] === '--hold') {
209
+ const pid = Number.parseInt(process.argv[3], 10);
210
+ const bundlePath = process.argv[4];
211
+ const parentPid = Number.parseInt(process.argv[5], 10);
212
+ const launchedBundlePath = process.argv[6] || '';
213
+ const target = { pid, bundlePath };
214
+ const hold = () => {
215
+ if (!parentAlive(parentPid)) process.exit(0);
216
+ const front = captureMacFrontmost();
217
+ if (front && front.pid === target.pid) return;
218
+ if (front && launchedBundlePath && !sameBundle(front.bundlePath, launchedBundlePath)) process.exit(0);
219
+ preserveMacFrontmost(target, launchedBundlePath);
220
+ };
221
+ hold();
222
+ setInterval(hold, HOLD_INTERVAL_MS);
223
+ setTimeout(() => process.exit(0), HOLD_MAX_MS);
224
+ }
225
+
226
+ module.exports = {
227
+ captureMacFrontmost,
228
+ captureMacFrontmostProcess,
229
+ macBackgroundOpenArgs,
230
+ preserveMacFrontmost,
231
+ preserveMacFrontmostProcess,
232
+ restoreMacFrontmost,
233
+ restoreMacFrontmostProcess,
234
+ startMacFocusHold,
235
+ stopMacFocusHold,
236
+ };
@@ -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,62 @@ 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
+ // Requires macOS or procps ps; a ps that rejects these flags prints nothing, which is treated as dead.
59
+ const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'ignore'],
62
+ }).stdout.trim();
63
+ return state !== '' && !state.startsWith('Z');
64
+ }
65
+
66
+ function uniquePids(pids) {
67
+ return [...new Set(pids)].filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid);
68
+ }
69
+
70
+ // Reap every live process that still owns this profile, then refuse to continue
71
+ // if any survive. `open -n` otherwise starts a second headed Chrome on the same
72
+ // user-data-dir while the previous instance is still dying.
73
+ function stopProfileProcessesSync(profile, { extraPids = [], timeoutMs = 5_000, waitForAppearanceMs = 0 } = {}) {
74
+ const deadline = Date.now() + timeoutMs;
75
+ const appearUntil = Date.now() + Math.max(0, waitForAppearanceMs);
76
+ let ownersSince = 0;
77
+ while (Date.now() < deadline) {
78
+ const pids = uniquePids([
79
+ ...profileProcessPids(profile),
80
+ ...extraPids.filter(pidAlive),
81
+ ]);
82
+ if (pids.length === 0) {
83
+ if (Date.now() >= appearUntil) return;
84
+ } else {
85
+ if (ownersSince === 0) ownersSince = Date.now();
86
+ signalPids(pids, Date.now() - ownersSince >= 500 ? 'SIGKILL' : 'SIGTERM');
87
+ }
88
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
89
+ }
90
+ const remaining = uniquePids([
91
+ ...profileProcessPids(profile),
92
+ ...extraPids.filter(pidAlive),
93
+ ]);
94
+ if (remaining.length > 0) {
95
+ throw new Error(
96
+ `Owned Chrome processes survived stop (pid ${remaining.join(', ')}). ` +
97
+ 'Next: mm-harness stop --adapter extension --target <checkout>',
98
+ );
99
+ }
100
+ }
101
+
46
102
  async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 } = {}) {
47
103
  const deadline = Date.now() + timeoutMs;
48
104
  let quietSince = Date.now();
@@ -66,4 +122,4 @@ async function stopProfileProcesses(profile, { quietMs = 0, timeoutMs = 5_000 }
66
122
  throw new Error(`Extension validation profile did not remain quiescent for ${quietMs}ms.`);
67
123
  }
68
124
 
69
- module.exports = { profileProcessPids, stopProfileProcesses };
125
+ 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
- *"--log $console_log"*|*"$console_log"*) kill "$pid" 2>/dev/null || true ;;
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 configuredProfile = process.env.CHROME_USER_DATA_DIR || path.join(runtimeAbs, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
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 !== process.pid && profiles.some((profile) => commandHasExactArg(match[2], "--user-data-dir", profile))) ownedBrowserPids.add(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,
@@ -8,7 +8,11 @@ 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: "pr-body", desc: "Render the publishable PR body: pr-description.md plus the recipe and run sections built from artifacts", args: ["render", "<task-dir>"], flags: ["--command", "--out", "--json"] },
15
+ { name: "config", desc: "Per-engineer locations: libraries.<name>, references.<adapter>", args: ["list", "path", "get", "set", "unset"], flags: ["--json"] },
12
16
  { name: "tutorial", desc: "Open the visual recipe tutorial", flags: ["--json", "--no-open"] },
13
17
  { name: "setup-base", desc: "Bootstrap numbered product checkouts", flags: ["--dir", "--counts", "--only", "--dry-run", "--force", "--json", "--show-config", "--reset-config", "--skip-harness-update"] },
14
18
  { name: "status", aliases: ["health", "home"], desc: "Home status + next commands", flags: ["--json", "--fast"] },
package/dist/cli.js CHANGED
@@ -25,6 +25,10 @@ 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";
31
+ import { handlePrBody } from "./commands/pr-body.js";
28
32
  import { parseArgs, targetPath } from "./commands/parse-args.js";
29
33
  const COMMANDS = {
30
34
  actions: handleActions,
@@ -132,6 +136,10 @@ async function main(argv) {
132
136
  if (command === "task") return handleTaskInit(argv.slice(1));
133
137
  if (command === "prepare") return handlePrepare(argv.slice(1));
134
138
  if (command === "last") return handleLast(parseArgs(argv.slice(1), command));
139
+ if (command === "review") return handleReview(argv.slice(1));
140
+ if (command === "domain") return handleDomain(argv.slice(1));
141
+ if (command === "config") return handleConfig(argv.slice(1));
142
+ if (command === "pr-body") return handlePrBody(argv.slice(1));
135
143
  const handler = COMMANDS[command];
136
144
  if (!handler) throw new Error(`Unknown command: ${command}`);
137
145
  return handler(parseArgs(argv.slice(1), command));
@@ -58,7 +58,40 @@ 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
+ "pr-body": {
81
+ options: options(HELP, JSON, {
82
+ "--command": value(),
83
+ "--out": value()
84
+ }),
85
+ positionals: [{ label: "action", choices: ["render"] }, { label: "task-dir" }],
86
+ minimumPositionals: 2
87
+ },
88
+ config: {
89
+ options: options(HELP, JSON),
90
+ positionals: [
91
+ { label: "action", choices: ["list", "path", "get", "set", "unset"] },
92
+ { label: "key" },
93
+ { label: "value" }
94
+ ]
62
95
  },
63
96
  tutorial: {
64
97
  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
+ };