@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.
- package/CHANGELOG.md +33 -1
- package/README.md +39 -0
- package/adapters/extension/ensure-browser.sh +33 -7
- package/adapters/extension/launch-browser.cjs +157 -62
- package/adapters/extension/lib/macos-focus.cjs +216 -12
- package/adapters/extension/lib/validation-process-ownership.cjs +58 -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 +5 -1
- package/dist/cli.js +8 -0
- package/dist/command-contract.js +34 -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/pr-body.js +31 -0
- package/dist/commands/review.js +135 -0
- package/dist/mm-harness-cli.js +93 -3
- package/dist/pr-body/render.js +136 -0
- package/dist/review/knowledge.js +513 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
9
|
-
'
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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('
|
|
23
|
-
'
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
*"
|
|
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,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));
|
package/dist/command-contract.js
CHANGED
|
@@ -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
|
+
};
|