@deeeed/metamask-harness 0.67.1 → 0.69.0
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 +21 -0
- package/adapters/mobile/metro-config.cjs +4 -0
- package/adapters/mobile/start-metro.sh +14 -3
- package/dist/adapters/mobile/prepare.js +12 -7
- package/dist/adapters/mobile/runtime-decision.js +11 -11
- package/dist/adapters/mobile/verify.js +56 -102
- package/dist/checkout-lock.js +62 -10
- package/dist/command-contract.js +8 -0
- package/dist/command-journal.js +52 -7
- package/dist/commands/call.js +3 -0
- package/dist/commands/checklist.js +13 -2
- package/dist/commands/fixtures.js +4 -5
- package/dist/commands/launch/index.js +19 -0
- package/dist/commands/list-executables.js +62 -11
- package/dist/commands/manifest.js +11 -4
- package/dist/commands/prepare.js +25 -4
- package/dist/commands/run-engine.js +8 -2
- package/dist/commands/run.js +3 -0
- package/dist/commands/runtime-launch.js +19 -7
- package/dist/commands/shared.js +48 -21
- package/dist/commands/status-watch.js +23 -0
- package/dist/execution-provenance.js +1 -1
- package/dist/harness.js +7 -11
- package/dist/json-stream.js +3 -0
- package/dist/live-adapter-contract.js +5 -0
- package/dist/mm-harness-cli.js +10 -1
- package/dist/task-view.js +8 -1
- package/docs/CONTRIBUTING.md +37 -0
- package/docs/RECIPES.md +10 -3
- package/library/actions/mobile/ui/native-navigation.mjs +18 -6
- package/package.json +5 -5
- package/scripts/completions.sh +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.69.0 - 2026-09-30
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Group and color recipe/action listings by domain, show library origins and paths separately, and support domain/source filters and domain/name/library sorting. Keep recipe parameter details in `--describe` and `--json`.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Accept repeated `task init --acceptance` flags at the public CLI boundary so Recipe Cook can record authored criteria in the task document and structured handoff.
|
|
14
|
+
- Report Mobile launch dependency blockers once with the dependency repair command; preserve the readiness cause instead of labelling it app logic or adding a conflicting doctor hint.
|
|
15
|
+
|
|
16
|
+
## 0.68.0 - 2026-09-29
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Verify Mobile readiness with one combined status/bridge recipe, retaining device-discovery retries and RPC checks; recognize Lite screens when the release hides the Pro toggle.
|
|
21
|
+
- Restart Metro when build environment inputs change and select the matching transform-cache generation without clearing caches.
|
|
22
|
+
- Forward child checklist progress through the public CLI while retaining the owning step's diff-validation gate.
|
|
23
|
+
- Keep operation and checkout ownership records readable under restrictive umasks, and exclude observation updates from product provenance.
|
|
24
|
+
- Retain command-specific logs and stage observations in `status --watch`; preserve nested operation and checkout ownership across long builds. Pin protocol 0.33.0, recipe-harness 0.21.1, agent-runtime 0.15.1 and expo-recipe 0.12.2 for a consistent runtime dependency tree.
|
|
25
|
+
|
|
5
26
|
## 0.67.1 - 2026-09-29
|
|
6
27
|
|
|
7
28
|
### Fixed
|
|
@@ -84,8 +84,12 @@ module.exports = async function harnessMetroConfig(baseConfig) {
|
|
|
84
84
|
`^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
|
|
85
85
|
blockListFlags(existingBlockList),
|
|
86
86
|
);
|
|
87
|
+
// Inline environment values affect transformed JavaScript. Keep separate
|
|
88
|
+
// cache generations for those inputs instead of deleting the shared cache.
|
|
89
|
+
const envFingerprint = process.env.MM_HARNESS_METRO_ENV_FINGERPRINT;
|
|
87
90
|
return {
|
|
88
91
|
...productConfig,
|
|
92
|
+
...(envFingerprint ? { cacheVersion: `${productConfig.cacheVersion ?? ''}:mm-env:${envFingerprint}` } : {}),
|
|
89
93
|
resolver: {
|
|
90
94
|
...productConfig.resolver,
|
|
91
95
|
blockList: appendBlockList(existingBlockList, tempPattern),
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
# Inputs:
|
|
11
11
|
# --target <metamask-mobile dir> (default $PWD)
|
|
12
12
|
# --port <number> (default: WATCHER_PORT env, else METRO_PORT, else 8081)
|
|
13
|
+
# --restart restart Metro while retaining its transform cache
|
|
13
14
|
# --clear reset Metro bundler cache before starting
|
|
14
15
|
# Outputs:
|
|
15
16
|
# Progress on stderr; exit 0 Metro ready; 1 failed to start; 2 bad args.
|
|
@@ -20,16 +21,19 @@ set -uo pipefail
|
|
|
20
21
|
TARGET="$PWD"
|
|
21
22
|
PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
|
|
22
23
|
CLEAR=false
|
|
24
|
+
RESTART=false
|
|
23
25
|
|
|
24
26
|
while [ "$#" -gt 0 ]; do
|
|
25
27
|
case "$1" in
|
|
26
28
|
--target) TARGET="$2"; shift 2 ;;
|
|
27
29
|
--port) PORT="$2"; shift 2 ;;
|
|
28
30
|
--clear) CLEAR=true; shift ;;
|
|
31
|
+
--restart) RESTART=true; shift ;;
|
|
29
32
|
-h|--help)
|
|
30
|
-
printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--clear]\n'
|
|
33
|
+
printf 'Usage: start-metro.sh [--target <dir>] [--port <port>] [--restart] [--clear]\n'
|
|
31
34
|
printf ' --target MetaMask Mobile checkout directory\n'
|
|
32
35
|
printf ' --port Metro port (default: WATCHER_PORT env or 8081)\n'
|
|
36
|
+
printf ' --restart restart while preserving the transform cache\n'
|
|
33
37
|
printf ' --clear restart with cleared cache\n'
|
|
34
38
|
exit 0
|
|
35
39
|
;;
|
|
@@ -60,6 +64,9 @@ export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
|
|
|
60
64
|
export MM_HARNESS_METRO_BASE_CONFIG="$PRODUCT_METRO_CONFIG"
|
|
61
65
|
export EXPO_OVERRIDE_METRO_CONFIG="$HARNESS_METRO_CONFIG"
|
|
62
66
|
MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT} MM_HARNESS_METRO_PROJECT_ROOT MM_HARNESS_METRO_BASE_CONFIG EXPO_OVERRIDE_METRO_CONFIG"
|
|
67
|
+
if [ -n "${MM_HARNESS_METRO_ENV_FINGERPRINT:-}" ]; then
|
|
68
|
+
MOBILE_METRO_REQUIRED_ENV="$MOBILE_METRO_REQUIRED_ENV MM_HARNESS_METRO_ENV_FINGERPRINT"
|
|
69
|
+
fi
|
|
63
70
|
|
|
64
71
|
# RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
|
|
65
72
|
# sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
|
|
@@ -212,8 +219,12 @@ fi
|
|
|
212
219
|
|
|
213
220
|
# Decide whether to (re)start or skip.
|
|
214
221
|
if metro_ready; then
|
|
215
|
-
if [ "$CLEAR" = true ]; then
|
|
216
|
-
|
|
222
|
+
if [ "$CLEAR" = true ] || [ "$RESTART" = true ]; then
|
|
223
|
+
if [ "$CLEAR" = true ]; then
|
|
224
|
+
printf 'Restarting Metro on port %s with cleared cache\n' "$PORT" >&2
|
|
225
|
+
else
|
|
226
|
+
printf 'Restarting Metro on port %s with its transform cache preserved\n' "$PORT" >&2
|
|
227
|
+
fi
|
|
217
228
|
stop_metro_listener || exit 1
|
|
218
229
|
rm -f "$PID_FILE"
|
|
219
230
|
elif metro_listener_valid; then
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
4
4
|
import { resolveAndExportMobileToolPaths } from "../../../library/actions/mobile/platform/tool-paths.mjs";
|
|
5
5
|
import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
|
|
6
|
+
import { shellQuote } from "../../commands/parse-args.js";
|
|
6
7
|
import { runnerDir } from "../../paths.js";
|
|
7
8
|
import {
|
|
8
9
|
decideMobileReadiness,
|
|
@@ -51,12 +52,14 @@ async function prepareMobile(target, opts = {}) {
|
|
|
51
52
|
});
|
|
52
53
|
if (report.decision === "blocked") {
|
|
53
54
|
const reasons = report.reasons.join(" ");
|
|
54
|
-
const next = report.userAction ??
|
|
55
|
+
const next = report.userAction ?? `mm-harness logs --target ${shellQuote(target)} --full`;
|
|
55
56
|
const msg = `mobile prepare blocked: ${reasons}
|
|
56
57
|
Next: ${next}`;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
return {
|
|
59
|
+
status: EXIT.runtime,
|
|
60
|
+
output: msg,
|
|
61
|
+
readinessBlock: { reasonCode: report.reasonCode, message: reasons, userAction: next }
|
|
62
|
+
};
|
|
60
63
|
}
|
|
61
64
|
const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
|
|
62
65
|
const rebuildNative = preflightMode === "rebuild-native" || preflightMode === "clean";
|
|
@@ -270,7 +273,8 @@ async function dispatchActionSequence(requestedActions, target, platform, json,
|
|
|
270
273
|
platform,
|
|
271
274
|
json,
|
|
272
275
|
preflightMode,
|
|
273
|
-
watcherPort
|
|
276
|
+
watcherPort,
|
|
277
|
+
pendingMetroEnvFingerprint
|
|
274
278
|
);
|
|
275
279
|
if (result.status !== 0) return result;
|
|
276
280
|
if (action.id === "yarn-setup") recordDepsBaseline(resolved);
|
|
@@ -378,7 +382,7 @@ function enableIosAccessibility(json) {
|
|
|
378
382
|
return false;
|
|
379
383
|
}
|
|
380
384
|
}
|
|
381
|
-
async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort) {
|
|
385
|
+
async function dispatchAction(action, target, platform, json, preflightMode = "fast", watcherPort, metroEnvFingerprint) {
|
|
382
386
|
const cwd = action.cwd ?? target;
|
|
383
387
|
switch (action.id) {
|
|
384
388
|
case "yarn-setup": {
|
|
@@ -396,7 +400,8 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
|
|
|
396
400
|
return spawnScriptStreaming(
|
|
397
401
|
leaf,
|
|
398
402
|
withWatcherPort(["--target", cwd, ...extra], watcherPort),
|
|
399
|
-
target
|
|
403
|
+
target,
|
|
404
|
+
{ MM_HARNESS_METRO_ENV_FINGERPRINT: metroEnvFingerprint ?? mobileMetroEnvCheck(path.resolve(target)).fingerprint }
|
|
400
405
|
);
|
|
401
406
|
}
|
|
402
407
|
case "start-console-forwarder": {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "@farmslot/recipe-harness/runtime/log-analysis";
|
|
13
13
|
import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
|
|
14
14
|
import { color } from "../../cli-color.js";
|
|
15
|
+
import { shellQuote } from "../../commands/parse-args.js";
|
|
15
16
|
import { recipeRuntimePath } from "../../paths.js";
|
|
16
17
|
import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
|
|
17
18
|
import { mobileProductMarkers } from "./deps-markers.js";
|
|
@@ -149,7 +150,7 @@ const launchActions = (target, clearMetro = false) => {
|
|
|
149
150
|
return actions;
|
|
150
151
|
};
|
|
151
152
|
function depsInstallCommand(resolved) {
|
|
152
|
-
return fs.existsSync(path.join(resolved, "node_modules")) ? "
|
|
153
|
+
return fs.existsSync(path.join(resolved, "node_modules")) ? "yarn setup:expo --no-build-ios --no-build-android" : "yarn install --immutable && yarn setup:expo --no-build-ios --no-build-android";
|
|
153
154
|
}
|
|
154
155
|
async function decideMobileReadiness(target, options = {}) {
|
|
155
156
|
const resolved = path.resolve(target);
|
|
@@ -157,18 +158,15 @@ async function decideMobileReadiness(target, options = {}) {
|
|
|
157
158
|
const fast = options.preflightMode === "fast";
|
|
158
159
|
const report = await computeMobileReadiness(resolved, options, fast);
|
|
159
160
|
if (fast && report.decision === "install") {
|
|
160
|
-
const standalone = depsInstallCommand(resolved);
|
|
161
161
|
return {
|
|
162
162
|
...report,
|
|
163
163
|
decision: "blocked",
|
|
164
164
|
reasonCode: "deps-not-ready",
|
|
165
165
|
reasons: [
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
...report.reasons,
|
|
167
|
+
"Quick launch does not install dependencies."
|
|
168
168
|
],
|
|
169
|
-
userAction: `
|
|
170
|
-
2. Orchestrated slot \u2014 run its deps/prepare phase.
|
|
171
|
-
After the selected action succeeds, re-run \`mm-harness launch <platform>\`.`,
|
|
169
|
+
userAction: `cd ${shellQuote(resolved)} && ${depsInstallCommand(resolved)}`,
|
|
172
170
|
actions: []
|
|
173
171
|
};
|
|
174
172
|
}
|
|
@@ -184,12 +182,14 @@ async function decideMobileReadiness(target, options = {}) {
|
|
|
184
182
|
env.status === "missing" ? "Metro environment has no successful bundle baseline; restart Metro to load the current environment without clearing its transform cache." : "Metro environment inputs changed since the successful bundle baseline; restart Metro without clearing its transform cache. Pass --clear-metro only if stale transforms are observed.",
|
|
185
183
|
...report.reasons
|
|
186
184
|
],
|
|
187
|
-
actions: report.decision === "ready" ? launchActions(resolved) : report.actions
|
|
185
|
+
actions: (report.decision === "ready" ? launchActions(resolved) : report.actions).map(
|
|
186
|
+
(action) => action.id === "start-metro" && !action.argv?.includes("--clear") && !action.argv?.includes("--restart") ? { ...action, argv: [...action.argv ?? [], "--restart"] } : action
|
|
187
|
+
)
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
|
-
function writeDepsNote(
|
|
190
|
+
function writeDepsNote() {
|
|
191
191
|
const stream = process.stderr;
|
|
192
|
-
const body =
|
|
192
|
+
const body = "No install baseline is recorded. Required package checks passed; timestamps alone do not establish dependency drift. Reusing the installed dependencies.";
|
|
193
193
|
stream.write(
|
|
194
194
|
`
|
|
195
195
|
${color("warn", "mm-harness note (deps):", { stream })} ${color("dim", body, { stream })}
|
|
@@ -204,7 +204,6 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
204
204
|
let deps = rawDeps;
|
|
205
205
|
if (rawDeps.status === "stale" && !rawDeps.hasBaseline) {
|
|
206
206
|
deps = { installed: rawDeps.installed, status: "current", hasBaseline: false };
|
|
207
|
-
writeDepsNote(resolved);
|
|
208
207
|
}
|
|
209
208
|
if (deps.status === "current") {
|
|
210
209
|
const absent = missingRequiredDeps(resolved);
|
|
@@ -323,6 +322,7 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
323
322
|
actions: installActions(resolved)
|
|
324
323
|
};
|
|
325
324
|
}
|
|
325
|
+
if (rawDeps.status === "stale" && !rawDeps.hasBaseline) writeDepsNote();
|
|
326
326
|
if (metro.status === "down") {
|
|
327
327
|
return {
|
|
328
328
|
schemaVersion: 1,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execFileSync
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { spawnScriptStreaming } from "../../commands/shared.js";
|
|
5
6
|
import { readRuntimeContextField } from "../../harness.js";
|
|
6
7
|
import { recipeHarnessPath, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "../../paths.js";
|
|
7
8
|
const USAGE = "Usage: mm-harness verify --adapter mobile -- [--target <metamask-mobile>] [--artifacts-dir <dir>] [--static-only] [--platform ios|android] [--preflight-mode fast|auto|default|rebuild-native|clean] [--no-auto-start]";
|
|
@@ -11,19 +12,6 @@ const AUTO_START_TRUE = ["1", "true", "TRUE", "True", "yes", "YES", "Yes", "on",
|
|
|
11
12
|
const AUTO_START_FALSE = ["0", "false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", ""];
|
|
12
13
|
const AUTO_START_REFUSAL = "Mobile auto-start is not allowed from product-local scripts. Start or prepare the app through the runner/slot runtime, then rerun verify with --no-auto-start.";
|
|
13
14
|
const EVM_READINESS_EXPRESSION = "(async function(){try{var engine=globalThis.Engine;var controller=engine&&engine.context&&engine.context.NetworkController;if(!controller||typeof controller.getSelectedNetworkClient!=='function')throw new Error('NetworkController selected client is unavailable');var client=controller.getSelectedNetworkClient();if(!client||!client.provider||typeof client.provider.request!=='function')throw new Error('selected EVM provider is unavailable');var result=await client.provider.request({method:'eth_blockNumber'});if(typeof result!=='string'||!/^0x[0-9a-f]+$/i.test(result))throw new Error('eth_blockNumber returned an invalid result');return {ok:true,result:result};}catch(error){return {ok:false,error:error&&(error.message||String(error))};}})()";
|
|
14
|
-
const STATUS_SMOKE_RECIPE = `{
|
|
15
|
-
"$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
|
|
16
|
-
"title": "Mobile runner status smoke",
|
|
17
|
-
"description": "Checks that the installed runner can read Mobile app status before live verification.",
|
|
18
|
-
"workflow": {
|
|
19
|
-
"entry": "status",
|
|
20
|
-
"nodes": {
|
|
21
|
-
"status": { "action": "app.status", "intent": "Read Mobile app status through the runner-owned bridge", "next": "done" },
|
|
22
|
-
"done": { "action": "end", "status": "pass" }
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
`;
|
|
27
15
|
const LIVE_SMOKE_RECIPE = `{
|
|
28
16
|
"$schema": "https://farmslot.io/schemas/recipe-v1.schema.json",
|
|
29
17
|
"title": "Mobile v1 runner live bridge smoke",
|
|
@@ -241,30 +229,21 @@ function mobilePortHolder(port) {
|
|
|
241
229
|
note: "HTTP /status probing is skipped during live verify because the React Native bridge is the authoritative readiness path for Mobile."
|
|
242
230
|
};
|
|
243
231
|
}
|
|
244
|
-
function spawnToLog(logPath, command, args, options = {}) {
|
|
232
|
+
async function spawnToLog(logPath, command, args, options = {}) {
|
|
245
233
|
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
fd,
|
|
258
|
-
timedOut ? `Timed out after ${(options.timeoutMs ?? 0) / 1e3}s: ${[command, ...args].join(" ")}
|
|
259
|
-
` : `${result.error.message}
|
|
260
|
-
`
|
|
261
|
-
);
|
|
262
|
-
return timedOut ? 124 : 127;
|
|
263
|
-
}
|
|
264
|
-
return result.status ?? 1;
|
|
265
|
-
} finally {
|
|
266
|
-
fs.closeSync(fd);
|
|
234
|
+
const result = await spawnScriptStreaming(command, args, options.cwd ?? process.cwd(), {
|
|
235
|
+
env: Object.fromEntries(Object.entries(options.env ?? process.env).filter((entry) => entry[1] !== void 0)),
|
|
236
|
+
timeoutMs: options.timeoutMs,
|
|
237
|
+
forward: "none",
|
|
238
|
+
stage: path.basename(logPath, ".log")
|
|
239
|
+
});
|
|
240
|
+
fs.writeFileSync(logPath, result.output);
|
|
241
|
+
if (result.error) {
|
|
242
|
+
fs.appendFileSync(logPath, `${result.error.message}
|
|
243
|
+
`);
|
|
244
|
+
return 127;
|
|
267
245
|
}
|
|
246
|
+
return result.timedOut ? 124 : result.status;
|
|
268
247
|
}
|
|
269
248
|
function readJsonFile(file) {
|
|
270
249
|
try {
|
|
@@ -372,42 +351,6 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
372
351
|
deviceTarget = platform === "android" ? resolveJsEnvValue(target, "ADB_SERIAL") || resolveJsEnvValue(target, "ANDROID_DEVICE") : resolveJsEnvValue(target, "IOS_SIMULATOR");
|
|
373
352
|
}
|
|
374
353
|
const deviceArgs = deviceTarget ? ["--device", deviceTarget] : [];
|
|
375
|
-
const statusSmokeRecipe = path.join(artifacts, "mobile-status-smoke.recipe.json");
|
|
376
|
-
const precheckLog = path.join(artifacts, "logs/app-status-precheck.log");
|
|
377
|
-
let liveRuntimeOk = false;
|
|
378
|
-
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
379
|
-
fs.writeFileSync(statusSmokeRecipe, STATUS_SMOKE_RECIPE);
|
|
380
|
-
const code = spawnToLog(precheckLog, runnerBin, [
|
|
381
|
-
"run",
|
|
382
|
-
statusSmokeRecipe,
|
|
383
|
-
"--adapter",
|
|
384
|
-
"mobile",
|
|
385
|
-
"--target",
|
|
386
|
-
target,
|
|
387
|
-
...deviceArgs,
|
|
388
|
-
"--watcher-port",
|
|
389
|
-
port,
|
|
390
|
-
"--artifacts-dir",
|
|
391
|
-
path.join(artifacts, "runner-status-smoke"),
|
|
392
|
-
"--json"
|
|
393
|
-
], { cwd: target });
|
|
394
|
-
if (code === 0) {
|
|
395
|
-
liveRuntimeOk = true;
|
|
396
|
-
break;
|
|
397
|
-
}
|
|
398
|
-
const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
|
|
399
|
-
fs.readFileSync(precheckLog, "utf8")
|
|
400
|
-
);
|
|
401
|
-
if (attempt < 5 && deviceNotFound) {
|
|
402
|
-
io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
|
|
403
|
-
await sleep(1e3);
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
if (!liveRuntimeOk && autoStart) io.err(AUTO_START_REFUSAL);
|
|
409
|
-
checks.push(liveRuntimeOk ? { name: "mobile runtime controllable precheck", status: "pass" } : { name: "mobile runtime controllable precheck", status: "fail", detail: "see logs/app-status-precheck.log" });
|
|
410
|
-
if (!liveRuntimeOk) status = "fail";
|
|
411
354
|
fixtureStatus = mobileFixtureStatus(target);
|
|
412
355
|
fs.writeFileSync(path.join(artifacts, "logs/fixture-status.json"), `${JSON.stringify(fixtureStatus)}
|
|
413
356
|
`);
|
|
@@ -423,12 +366,12 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
423
366
|
portHolder = mobilePortHolder(port);
|
|
424
367
|
fs.writeFileSync(path.join(artifacts, "logs/port-holder.json"), `${JSON.stringify(portHolder)}
|
|
425
368
|
`);
|
|
426
|
-
if (status
|
|
369
|
+
if (!checks.some((check) => check.status === "fail") && portHolder.listening) {
|
|
427
370
|
const waiter = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
|
|
428
371
|
if (!fs.existsSync(waiter)) {
|
|
429
372
|
addNote("Mobile bridge settle skipped: wait-for-bridge.sh not found under the runner adapters; reinstall the runner.");
|
|
430
373
|
} else {
|
|
431
|
-
const waiterCode = spawnToLog(
|
|
374
|
+
const waiterCode = await spawnToLog(
|
|
432
375
|
path.join(artifacts, "logs/wait-for-bridge.log"),
|
|
433
376
|
"bash",
|
|
434
377
|
[waiter, "--target", target, "--port", port, "--platform", platform],
|
|
@@ -446,34 +389,45 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
446
389
|
fs.writeFileSync(liveSmokeRecipe, LIVE_SMOKE_RECIPE);
|
|
447
390
|
const iosSimulatorResolved = resolveJsEnvValue(target, "IOS_SIMULATOR");
|
|
448
391
|
const adbSerialResolved = resolveJsEnvValue(target, "ADB_SERIAL");
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
392
|
+
let liveSmokeCode = 1;
|
|
393
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
394
|
+
liveSmokeCode = await spawnToLog(
|
|
395
|
+
path.join(artifacts, "logs/runner-live-smoke.log"),
|
|
396
|
+
runnerBin,
|
|
397
|
+
[
|
|
398
|
+
"run",
|
|
399
|
+
liveSmokeRecipe,
|
|
400
|
+
"--adapter",
|
|
401
|
+
"mobile",
|
|
402
|
+
"--target",
|
|
403
|
+
target,
|
|
404
|
+
...deviceArgs,
|
|
405
|
+
"--watcher-port",
|
|
406
|
+
port,
|
|
407
|
+
"--artifacts-dir",
|
|
408
|
+
path.join(artifacts, "runner-live-smoke"),
|
|
409
|
+
"--json"
|
|
410
|
+
],
|
|
411
|
+
{
|
|
412
|
+
cwd: target,
|
|
413
|
+
env: {
|
|
414
|
+
...process.env,
|
|
415
|
+
METAMASK_RECIPE_AUTO_HUD: "0",
|
|
416
|
+
IOS_SIMULATOR: process.env.IOS_SIMULATOR || iosSimulatorResolved,
|
|
417
|
+
ADB_SERIAL: process.env.ADB_SERIAL || adbSerialResolved,
|
|
418
|
+
ANDROID_SERIAL: process.env.ANDROID_SERIAL || adbSerialResolved
|
|
419
|
+
}
|
|
474
420
|
}
|
|
475
|
-
|
|
476
|
-
|
|
421
|
+
);
|
|
422
|
+
if (liveSmokeCode === 0) break;
|
|
423
|
+
const deviceNotFound = /"code"[ \t]*:[ \t]*"DEVICE_NOT_FOUND"/u.test(
|
|
424
|
+
fs.readFileSync(path.join(artifacts, "logs/runner-live-smoke.log"), "utf8")
|
|
425
|
+
);
|
|
426
|
+
if (!deviceNotFound || attempt === 5) break;
|
|
427
|
+
io.err(`Waiting for Mobile device discovery (${attempt}/5): ${deviceTarget || "configured target"}`);
|
|
428
|
+
await sleep(1e3);
|
|
429
|
+
}
|
|
430
|
+
if (liveSmokeCode !== 0 && autoStart) io.err(AUTO_START_REFUSAL);
|
|
477
431
|
if (liveSmokeCode === 0) {
|
|
478
432
|
checks.push({ name: "runner v1 live bridge smoke", status: "pass" });
|
|
479
433
|
} else {
|
|
@@ -488,7 +442,7 @@ async function runMobileVerify(argv, io = defaultIo) {
|
|
|
488
442
|
fs.writeFileSync(evmLog, `Mobile bridge is missing at ${bridge}
|
|
489
443
|
`);
|
|
490
444
|
} else {
|
|
491
|
-
const evmCode = spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
|
|
445
|
+
const evmCode = await spawnToLog(evmLog, process.execPath, [bridge, "eval-async", EVM_READINESS_EXPRESSION], {
|
|
492
446
|
timeoutMs: 25e3,
|
|
493
447
|
env: {
|
|
494
448
|
...process.env,
|
package/dist/checkout-lock.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureRuntimeDirectory, processIdentity } from "@farmslot/recipe-harness/runtime/operation";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -5,15 +6,19 @@ import { recipeRuntimeDir } from "./paths.js";
|
|
|
5
6
|
function acquireCheckoutLock(target, operation) {
|
|
6
7
|
const repoRoot = fs.realpathSync(target);
|
|
7
8
|
const lockPath = path.join(repoRoot, recipeRuntimeDir(), "sandbox.lock");
|
|
8
|
-
|
|
9
|
+
ensureRuntimeDirectory(path.dirname(lockPath));
|
|
9
10
|
const inheritedToken = process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
|
|
10
11
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
11
12
|
try {
|
|
13
|
+
const identity = processIdentity(process.pid);
|
|
14
|
+
if (!identity) throw new Error("Cannot identify checkout lock owner process");
|
|
12
15
|
const fd = fs.openSync(lockPath, "wx", 384);
|
|
13
|
-
|
|
16
|
+
fs.fchmodSync(fd, 384);
|
|
17
|
+
const token = randomUUID();
|
|
14
18
|
const owner = {
|
|
15
19
|
schemaVersion: 1,
|
|
16
20
|
pid: process.pid,
|
|
21
|
+
processStartedAt: identity,
|
|
17
22
|
operation,
|
|
18
23
|
repoRoot,
|
|
19
24
|
token,
|
|
@@ -22,30 +27,38 @@ function acquireCheckoutLock(target, operation) {
|
|
|
22
27
|
fs.writeFileSync(fd, `${JSON.stringify(owner, null, 2)}
|
|
23
28
|
`);
|
|
24
29
|
fs.closeSync(fd);
|
|
25
|
-
|
|
30
|
+
process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = token;
|
|
26
31
|
return {
|
|
27
32
|
path: lockPath,
|
|
28
33
|
release: () => {
|
|
29
34
|
const current = readOwner(lockPath);
|
|
30
|
-
if (current?.pid === process.pid && current.token === token
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
if (current?.pid === process.pid && current.token === token && !childrenAlive(current)) {
|
|
36
|
+
fs.rmSync(lockPath, { force: true });
|
|
37
|
+
fs.rmSync(path.join(path.dirname(lockPath), "lock-members", token), { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
if (process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN === token) {
|
|
40
|
+
if (inheritedToken) process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN = inheritedToken;
|
|
41
|
+
else delete process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN;
|
|
33
42
|
}
|
|
34
43
|
}
|
|
35
44
|
};
|
|
36
45
|
} catch (error) {
|
|
37
46
|
if (error.code !== "EEXIST") throw error;
|
|
38
47
|
const owner = readOwner(lockPath);
|
|
39
|
-
if (inheritedToken && owner?.token === inheritedToken && owner.repoRoot === repoRoot && typeof owner.pid === "number" &&
|
|
48
|
+
if (inheritedToken && owner?.token === inheritedToken && owner.repoRoot === repoRoot && typeof owner.pid === "number" && ownerAlive(owner)) {
|
|
49
|
+
trackCheckoutChild(repoRoot, process.pid);
|
|
40
50
|
return { path: lockPath, release: () => void 0 };
|
|
41
51
|
}
|
|
42
|
-
if (owner && typeof owner.pid === "number" &&
|
|
52
|
+
if (owner && typeof owner.pid === "number" && ownerAlive(owner)) {
|
|
43
53
|
return {
|
|
44
54
|
path: lockPath,
|
|
45
55
|
owner,
|
|
46
56
|
message: `checkout sandbox is busy with ${String(owner.operation ?? "another operation")} (pid ${owner.pid}).`
|
|
47
57
|
};
|
|
48
58
|
}
|
|
59
|
+
if (!owner || childrenAlive(owner)) {
|
|
60
|
+
return { path: lockPath, owner, message: `checkout sandbox owner is unreadable or has surviving child processes. Inspect ${lockPath} and its lock-members directory; remove the lock only after verifying all recorded processes have exited.` };
|
|
61
|
+
}
|
|
49
62
|
claimStaleLock(lockPath, owner);
|
|
50
63
|
}
|
|
51
64
|
}
|
|
@@ -61,12 +74,14 @@ function claimStaleLock(lockPath, expectedOwner) {
|
|
|
61
74
|
}
|
|
62
75
|
try {
|
|
63
76
|
const claimedOwner = readOwner(claimedPath);
|
|
64
|
-
if (!sameOwner(claimedOwner, expectedOwner) || claimedOwner && typeof claimedOwner.pid === "number" &&
|
|
77
|
+
if (!sameOwner(claimedOwner, expectedOwner) || claimedOwner && typeof claimedOwner.pid === "number" && (ownerAlive(claimedOwner) || childrenAlive(claimedOwner))) {
|
|
65
78
|
try {
|
|
66
79
|
fs.linkSync(claimedPath, lockPath);
|
|
67
80
|
} catch (error) {
|
|
68
81
|
if (error.code !== "EEXIST") throw error;
|
|
69
82
|
}
|
|
83
|
+
} else if (typeof claimedOwner?.token === "string" && /^[a-f0-9-]{36}$/.test(claimedOwner.token)) {
|
|
84
|
+
fs.rmSync(path.join(path.dirname(lockPath), "lock-members", claimedOwner.token), { recursive: true, force: true });
|
|
70
85
|
}
|
|
71
86
|
} finally {
|
|
72
87
|
fs.rmSync(claimedPath, { force: true });
|
|
@@ -84,6 +99,42 @@ function readOwner(lockPath) {
|
|
|
84
99
|
return null;
|
|
85
100
|
}
|
|
86
101
|
}
|
|
102
|
+
function ownerAlive(owner) {
|
|
103
|
+
if (typeof owner.pid !== "number") return true;
|
|
104
|
+
if (typeof owner.processStartedAt === "string") return processIdentity(owner.pid) === owner.processStartedAt;
|
|
105
|
+
return processAlive(owner.pid);
|
|
106
|
+
}
|
|
107
|
+
function childrenAlive(owner) {
|
|
108
|
+
if (typeof owner.repoRoot !== "string" || typeof owner.token !== "string" || !/^[a-f0-9-]{36}$/.test(owner.token)) return false;
|
|
109
|
+
const directory = path.join(owner.repoRoot, recipeRuntimeDir(), "lock-members", owner.token);
|
|
110
|
+
if (!fs.existsSync(directory)) return false;
|
|
111
|
+
return fs.readdirSync(directory).filter((name) => name.endsWith(".json")).some((name) => {
|
|
112
|
+
const child = readOwner(path.join(directory, name));
|
|
113
|
+
if (child && typeof child.processGroupId === "number" && child.processGroupId > 0) return processAlive(-child.processGroupId);
|
|
114
|
+
return !child || typeof child.pid !== "number" || typeof child.processStartedAt !== "string" || child.pid !== owner.pid && processIdentity(child.pid) === child.processStartedAt;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function trackCheckoutChild(target, pid, ownsProcessGroup = false) {
|
|
118
|
+
const repoRoot = fs.realpathSync(target);
|
|
119
|
+
const lockPath = path.join(repoRoot, recipeRuntimeDir(), "sandbox.lock");
|
|
120
|
+
const owner = readOwner(lockPath);
|
|
121
|
+
if (!owner || owner.token !== process.env.MM_HARNESS_CHECKOUT_LOCK_TOKEN || typeof owner.token !== "string" || !/^[a-f0-9-]{36}$/.test(owner.token)) return () => void 0;
|
|
122
|
+
const identity = processIdentity(pid);
|
|
123
|
+
if (!identity && !ownsProcessGroup) return () => void 0;
|
|
124
|
+
const directory = path.join(repoRoot, recipeRuntimeDir(), "lock-members", owner.token);
|
|
125
|
+
ensureRuntimeDirectory(directory);
|
|
126
|
+
const file = path.join(directory, `${pid}.json`);
|
|
127
|
+
const previous = readOwner(file);
|
|
128
|
+
const group = ownsProcessGroup || previous?.processStartedAt === identity && previous?.processGroupId === pid;
|
|
129
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
130
|
+
fs.writeFileSync(temporary, JSON.stringify({ pid, processStartedAt: identity, ...group ? { processGroupId: pid } : {} }), { mode: 384 });
|
|
131
|
+
fs.chmodSync(temporary, 384);
|
|
132
|
+
fs.renameSync(temporary, file);
|
|
133
|
+
return () => {
|
|
134
|
+
const current = readOwner(file);
|
|
135
|
+
if (current?.pid === pid && current.processStartedAt === identity) fs.rmSync(file, { force: true });
|
|
136
|
+
};
|
|
137
|
+
}
|
|
87
138
|
function processAlive(pid) {
|
|
88
139
|
try {
|
|
89
140
|
process.kill(pid, 0);
|
|
@@ -93,5 +144,6 @@ function processAlive(pid) {
|
|
|
93
144
|
}
|
|
94
145
|
}
|
|
95
146
|
export {
|
|
96
|
-
acquireCheckoutLock
|
|
147
|
+
acquireCheckoutLock,
|
|
148
|
+
trackCheckoutChild
|
|
97
149
|
};
|
package/dist/command-contract.js
CHANGED
|
@@ -168,6 +168,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
168
168
|
"--title": value(),
|
|
169
169
|
"--task-text": value(),
|
|
170
170
|
"--task-file": value(),
|
|
171
|
+
"--acceptance": value(),
|
|
171
172
|
"--ticket": value(),
|
|
172
173
|
"--source-ref": value(),
|
|
173
174
|
"--surface": value(),
|
|
@@ -241,6 +242,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
241
242
|
call: {
|
|
242
243
|
options: options(HELP, JSON, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
|
|
243
244
|
"--list": bool(),
|
|
245
|
+
"--domain": value(),
|
|
246
|
+
"--source": value(),
|
|
247
|
+
"--sort": value(["domain", "name", "library"]),
|
|
244
248
|
"--arg": value()
|
|
245
249
|
}),
|
|
246
250
|
positionals: [{ label: "action" }],
|
|
@@ -253,6 +257,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
253
257
|
run: {
|
|
254
258
|
options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, RUN_FUNDING, {
|
|
255
259
|
"--list": bool(),
|
|
260
|
+
"--domain": value(),
|
|
261
|
+
"--source": value(),
|
|
262
|
+
"--sort": value(["domain", "name", "library"]),
|
|
256
263
|
"--plan": bool(),
|
|
257
264
|
"--proof": bool(),
|
|
258
265
|
"--describe": bool(),
|
|
@@ -425,6 +432,7 @@ function validatePublicInvocation(argv, examples = {}) {
|
|
|
425
432
|
}
|
|
426
433
|
const { name, contract } = resolved;
|
|
427
434
|
const tokens = argv.slice(1);
|
|
435
|
+
if (name === "checklist" && tokens[0] === "mark" && tokens[1] && !tokens[1].startsWith("-") && tokens[2] === "sub") return null;
|
|
428
436
|
const positionals = [];
|
|
429
437
|
const seenOptions = /* @__PURE__ */ new Set();
|
|
430
438
|
const validOptions = Object.keys(contract.options);
|