@deeeed/metamask-harness 0.4.0 → 0.5.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/adapters/core/surface.js +53 -0
  3. package/dist/adapters/extension/ensure-ready.js +109 -0
  4. package/dist/adapters/extension/extension-id.js +62 -0
  5. package/dist/adapters/extension/runtime-decision.js +305 -0
  6. package/dist/adapters/extension/runtime.js +324 -0
  7. package/dist/adapters/extension/surface.js +69 -0
  8. package/dist/adapters/mobile/deps-markers.js +22 -0
  9. package/dist/adapters/mobile/prepare.js +146 -0
  10. package/dist/adapters/mobile/provision.js +465 -0
  11. package/dist/adapters/mobile/runtime-decision.js +315 -0
  12. package/dist/adapters/mobile/surface.js +54 -0
  13. package/dist/adapters/slot-ports.js +146 -0
  14. package/dist/adapters/surface.js +14 -0
  15. package/dist/adapters.js +485 -0
  16. package/dist/cli-color.js +79 -0
  17. package/dist/cli-commands.js +224 -0
  18. package/dist/cli-version.js +111 -0
  19. package/dist/cli.js +1571 -0
  20. package/dist/commands/debug.js +56 -0
  21. package/dist/commands/fixtures.js +153 -0
  22. package/dist/commands/launch.js +325 -0
  23. package/dist/commands/logs.js +73 -0
  24. package/dist/commands/shared.js +157 -0
  25. package/dist/commands/update.js +243 -0
  26. package/dist/completions-cache.js +53 -0
  27. package/dist/doctor.js +169 -0
  28. package/dist/harness.js +627 -0
  29. package/dist/heal-bounds.js +120 -0
  30. package/dist/index.js +25 -0
  31. package/dist/leaf-invoke.js +19 -0
  32. package/dist/live-adapter-contract.js +240 -0
  33. package/dist/manifest.js +37 -0
  34. package/dist/mm-harness-cli.js +521 -0
  35. package/dist/paths.js +179 -0
  36. package/dist/progress.js +94 -0
  37. package/dist/recording-target.js +133 -0
  38. package/dist/run-recording.js +271 -0
  39. package/dist/runner.js +88 -0
  40. package/dist/types.js +0 -0
  41. package/docs/CLI-SPEC.md +26 -3
  42. package/package.json +5 -1
  43. package/src/adapters/core/surface.ts +15 -0
  44. package/src/adapters/extension/surface.ts +20 -3
  45. package/src/adapters/mobile/provision.ts +594 -0
  46. package/src/adapters/mobile/surface.ts +16 -4
  47. package/src/adapters/slot-ports.ts +1 -1
  48. package/src/adapters/surface.ts +35 -0
  49. package/src/cli-commands.ts +1 -1
  50. package/src/cli.ts +149 -6
  51. package/src/harness.ts +140 -3
  52. package/src/mm-harness-cli.ts +52 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-07-05
4
+
5
+ Minor release: thin Runway provisioning (harness-owned slot provisioning with a shared artifact cache) plus compiled-dist packaging.
6
+
7
+ ### Added
8
+ - **`mm-harness provision runway`** — provisions a mobile slot with exactly: resolve the latest Runway (expo-dev-build) artifact, download it ONCE into a shared machine cache (`~/.cache/metamask-harness/runway/<runId>/`, sha256+size integrity, corrupt entries re-downloaded, never a slot failure), create the simulator if missing, install the dev client on it. Zero yarn/deps/Metro — those stay dispatch-time (lazy, presence-authoritative readiness unchanged). `install --runway` is the overlay-path equivalent (covered by its own contract test). Baseline recorded so doctor reports provisioned-but-deps-pending as a normal state.
9
+ - **Compiled `dist/` for published installs** — the npm tarball ships esbuild-compiled JS; `npm i -g` installs run the dist entry (~0.21s cold vs ~0.37s tsx). Source checkouts without `dist/` keep the tsx dev path; `bin/mm-harness` prefers dist when present. Packaging contract test (pack → global install → dist entry runs).
10
+
11
+ ### Changed
12
+ - Runway provisioning callers (farm installer, mobile-farm `runway` prepare profile) delegate to the harness; the profile no longer runs a deps phase and a contract test enforces that.
13
+
3
14
  ## 0.4.0 - 2026-07-04
4
15
 
5
16
  Minor release: the adapter-surface architecture plus the day's feature wave (0.3.6-0.3.9 shipped fixes as they were validated live; this cut marks the surface additions honestly).
@@ -34,7 +45,15 @@ Minor release: the adapter-surface architecture plus the day's feature wave (0.3
34
45
 
35
46
  ## Unreleased
36
47
 
48
+ ### Added
49
+ - Add `mm-harness provision` for thin mobile Runway artifact provisioning with a shared integrity-checked cache and simulator install path.
50
+ - Add `mm-harness provision runway ios --resolve-only` to return Runway run/revision/artifact metadata without touching simulator, cache, download, install, or baseline state.
51
+ - **Dist publish path** — `npm run build` compiles `src/` to `dist/`; `npm pack` / global install runs the compiled CLI entry (`dist/mm-harness-cli.js`) while a source checkout without `dist/` keeps the tsx+src dev path. Contracts: `packaging-dist-install`, `packaging-dev-checkout`.
52
+
37
53
  ### Fixed
54
+ - `mm-harness provision runway ios` now routes through the same Runway provisioning surface as `install --runway`, so overlay dispatch accepts the compatibility command instead of rejecting it as an unsupported harness action.
55
+ - Runway provisioning now accepts farm context flags (`--slot`, `--watcher-port`, `--runtime-dir`), records slot/watcher/runtime-dir baselines correctly, ignores stale provision baselines unless the app is still installed on the simulator, and emits rerunnable `Next:` commands from the actual invocation.
56
+ - Runway iOS artifact extraction now handles the live nested zip layout and app bundle names other than `MetaMask.app` while keeping the cached app directory integrity hash/size contract.
38
57
  - **Mobile preflight no longer re-runs `yarn setup` on every launch** — the deps readiness decision escalated an mtime hint to `stale` and ran the FULL `yarn setup` (submodules + inpage bridge + jetify + pods) inside the launch phase on every invocation in orchestrated (farmslot) contexts. `depsCheck` reports `stale` for a no-baseline checkout whenever a manifest is newer than the install markers; the orchestrator's git phase refreshes tracked-file mtimes on every sync while its deps phase leaves `node_modules` untouched, so "manifest newer than markers" is normal and not proof of drift — yet it duplicated the orchestrator's deps work and turned a fast preflight into 15+ minutes (the behavior that also looked like a hang before 0.3.8's streaming). **Presence is now authoritative:** without a recorded baseline an mtime-only `stale` is trusted as `current` (with a one-line stderr warning), so a churned-but-present checkout launches with zero `yarn` in both tiers. Genuine drift is still caught by the recorded-baseline fingerprint. `mm-harness launch` (quick tier / no `--build`) also dropped `preflightMode` before deciding, so the fast contract never took effect; it is threaded through now.
39
58
  - **Fast tier never installs; genuine gaps teach the orchestrator** — in the fast tier (quick launch / orchestrated), deps that genuinely need work (absent markers, an absent required package, a fingerprint drift, or Metro unable to resolve a module) return a teaching block naming the orchestrator deps/prepare phase (plus the standalone `yarn setup:expo` / `launch --build` remedies) instead of an implicit setup — deps are the orchestrator's contract. The standalone/full tier still installs.
40
59
  - **Deps baseline recorded the instant a setup leaf succeeds** — `prepareMobile` records the deps fingerprint baseline immediately after any successful `yarn-setup`, unconditionally (previously only a later `record: true` re-decide did, gated on the original decision being `install` and on `RECIPE_UP_INSTALL_ATTEMPTED`). Genuine-drift detection is fingerprint-based and self-sustaining after one good install.
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+ import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
+ const coreSurface = {
4
+ adapter: "core",
5
+ headless: true,
6
+ resolveSlotPorts() {
7
+ },
8
+ async runtimeStatus(target) {
9
+ const deps = depsCheck(path.resolve(target));
10
+ const ready = deps.status === "current";
11
+ return {
12
+ decision: ready ? "ready" : "install",
13
+ reasonCode: ready ? "deps-present" : `deps-${deps.status}`,
14
+ reasons: [
15
+ ready ? "Core is headless; dependencies are installed. Run recipes with mm-harness run." : "Core is headless; dependencies are not fully installed."
16
+ ],
17
+ deps: deps.status
18
+ };
19
+ },
20
+ runwayProvision: {
21
+ async run(target, options) {
22
+ return {
23
+ schemaVersion: 1,
24
+ command: "provision",
25
+ adapter: "core",
26
+ target: path.resolve(target),
27
+ status: "fail",
28
+ exitCode: 2,
29
+ error: { code: "UNSUPPORTED_ADAPTER", message: "core is headless and has no mobile simulator to provision.", userAction: options.rerunCommand || "mm-harness run <recipe> --adapter core" }
30
+ };
31
+ }
32
+ },
33
+ devServer: {
34
+ describe: () => "no dev server (headless)",
35
+ stop() {
36
+ return {
37
+ kind: "headless",
38
+ message: "core is headless \u2014 no dev server runs for a core checkout",
39
+ userAction: "use mm-harness verify (readiness) or mm-harness run (execute a recipe)"
40
+ };
41
+ }
42
+ },
43
+ logSources() {
44
+ return [];
45
+ },
46
+ hints: {
47
+ launch: "mm-harness run <recipe> # run recipes against the headless core",
48
+ relaunch: "mm-harness verify"
49
+ }
50
+ };
51
+ export {
52
+ coreSurface
53
+ };
@@ -0,0 +1,109 @@
1
+ import path from "node:path";
2
+ import { resolveExtensionId } from "./extension-id.js";
3
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
+ async function jsonList(port) {
5
+ try {
6
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(5e3) });
7
+ if (!res.ok) return [];
8
+ const value = await res.json();
9
+ return Array.isArray(value) ? value : [];
10
+ } catch {
11
+ return [];
12
+ }
13
+ }
14
+ async function closeTab(port, id) {
15
+ try {
16
+ await fetch(`http://127.0.0.1:${port}/json/close/${id}`, { signal: AbortSignal.timeout(4e3) });
17
+ } catch {
18
+ }
19
+ }
20
+ async function activateTab(port, id) {
21
+ try {
22
+ await fetch(`http://127.0.0.1:${port}/json/activate/${id}`, { signal: AbortSignal.timeout(4e3) });
23
+ } catch {
24
+ }
25
+ }
26
+ function isStrayTab(url) {
27
+ if (typeof url !== "string") return false;
28
+ return /^chrome:\/\/newtab\/?$/.test(url) || /^chrome:\/\/new-tab-page\b/.test(url) || url === "about:blank";
29
+ }
30
+ async function openHome(port, extensionId) {
31
+ const endpoint = `http://127.0.0.1:${port}/json/new?${encodeURIComponent(`chrome-extension://${extensionId}/home.html`)}`;
32
+ try {
33
+ let res = await fetch(endpoint, { method: "PUT", signal: AbortSignal.timeout(8e3) });
34
+ if (res.status === 404 || res.status === 405) res = await fetch(endpoint, { signal: AbortSignal.timeout(8e3) });
35
+ return res.ok;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ function homePages(targets, extensionId) {
41
+ const prefix = `chrome-extension://${extensionId}`;
42
+ return targets.filter(
43
+ (t) => t.type === "page" && typeof t.url === "string" && t.url.startsWith(prefix) && t.url.includes("/home.html") && t.id
44
+ );
45
+ }
46
+ async function ensureExtensionReady(target, options) {
47
+ const resolved = path.resolve(target);
48
+ const { cdpPort } = options;
49
+ const base = (extra) => ({
50
+ schemaVersion: 1,
51
+ adapter: "extension",
52
+ target: resolved,
53
+ cdpPort,
54
+ extensionId: null,
55
+ opened: false,
56
+ homeTabs: { before: 0, closed: 0, after: 0 },
57
+ ready: false,
58
+ reasonCode: "unknown",
59
+ health: { status: "unknown", findings: [] },
60
+ ...extra
61
+ });
62
+ const { extensionId } = await resolveExtensionId(resolved, { cdpPort });
63
+ if (!extensionId) return base({ reasonCode: "no-extension-id" });
64
+ const homes = homePages(await jsonList(cdpPort), extensionId);
65
+ const before = homes.length;
66
+ let opened = false;
67
+ let closed = 0;
68
+ if (homes.length > 1) {
69
+ for (const h of homes) {
70
+ await closeTab(cdpPort, String(h.id));
71
+ closed += 1;
72
+ }
73
+ await sleep(500);
74
+ opened = await openHome(cdpPort, extensionId);
75
+ await sleep(1500);
76
+ } else if (homes.length === 0) {
77
+ opened = await openHome(cdpPort, extensionId);
78
+ await sleep(1500);
79
+ }
80
+ const converged = await jsonList(cdpPort);
81
+ const homesNow = homePages(converged, extensionId);
82
+ if (homesNow.length >= 1) {
83
+ for (const t of converged) {
84
+ if (t.type === "page" && t.id && isStrayTab(t.url)) await closeTab(cdpPort, String(t.id));
85
+ }
86
+ if (homesNow[0]?.id) await activateTab(cdpPort, String(homesNow[0].id));
87
+ }
88
+ const after = homePages(await jsonList(cdpPort), extensionId).length;
89
+ let health = { status: "unknown", findings: [] };
90
+ try {
91
+ const { checkExtensionRuntimeHealth } = await import("./runtime.js");
92
+ const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
93
+ health = { status: report.status, findings: report.findings };
94
+ } catch (error) {
95
+ health = { status: "FAIL", findings: [error instanceof Error ? error.message : String(error)] };
96
+ }
97
+ const ready = health.status === "PASS" && after === 1;
98
+ return base({
99
+ extensionId,
100
+ opened,
101
+ homeTabs: { before, closed, after },
102
+ ready,
103
+ reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : "unhealthy",
104
+ health
105
+ });
106
+ }
107
+ export {
108
+ ensureExtensionReady
109
+ };
@@ -0,0 +1,62 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const DIST_MANIFEST = "dist/chrome/manifest.json";
5
+ function extensionIdFromKey(keyBase64) {
6
+ const der = Buffer.from(keyBase64, "base64");
7
+ const hash = crypto.createHash("sha256").update(der).digest();
8
+ let id = "";
9
+ for (let i = 0; i < 16; i += 1) {
10
+ id += String.fromCharCode(97 + (hash[i] >> 4));
11
+ id += String.fromCharCode(97 + (hash[i] & 15));
12
+ }
13
+ return id;
14
+ }
15
+ function idFromDistManifest(target) {
16
+ const manifestPath = path.join(target, DIST_MANIFEST);
17
+ if (!fs.existsSync(manifestPath)) return null;
18
+ try {
19
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
20
+ return typeof manifest.key === "string" && manifest.key.length > 0 ? extensionIdFromKey(manifest.key) : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ async function cdpExtensionIds(cdpPort) {
26
+ try {
27
+ const res = await fetch(`http://127.0.0.1:${cdpPort}/json/list`, { signal: AbortSignal.timeout(4e3) });
28
+ if (!res.ok) return [];
29
+ const targets = await res.json();
30
+ const ids = /* @__PURE__ */ new Set();
31
+ for (const t of targets) {
32
+ const m = /^chrome-extension:\/\/([^/]+)/u.exec(String(t.url ?? ""));
33
+ if (m) ids.add(m[1]);
34
+ }
35
+ return [...ids];
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+ async function resolveExtensionId(target, options = {}) {
41
+ const resolved = path.resolve(target);
42
+ const fromKey = idFromDistManifest(resolved);
43
+ let cdpIds = null;
44
+ if (options.cdpPort) cdpIds = await cdpExtensionIds(options.cdpPort);
45
+ if (fromKey) {
46
+ return {
47
+ adapter: "extension",
48
+ target: resolved,
49
+ extensionId: fromKey,
50
+ source: "manifest-key",
51
+ verified: cdpIds ? cdpIds.includes(fromKey) : null
52
+ };
53
+ }
54
+ if (cdpIds && cdpIds.length === 1) {
55
+ return { adapter: "extension", target: resolved, extensionId: cdpIds[0], source: "cdp-target", verified: true };
56
+ }
57
+ return { adapter: "extension", target: resolved, extensionId: null, source: "none", verified: cdpIds ? false : null };
58
+ }
59
+ export {
60
+ extensionIdFromKey,
61
+ resolveExtensionId
62
+ };
@@ -0,0 +1,305 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import {
6
+ depsCheck,
7
+ INSTALL_MARKERS,
8
+ recordDepsBaseline
9
+ } from "@farmslot/recipe-harness/runtime/deps-readiness";
10
+ import { recipeHarnessPath, recipeWatchLogCandidates } from "../../paths.js";
11
+ const WEBPACK_DIRECT_INPUTS = ["package.json", "yarn.lock", ".yarnrc.yml", ".tool-versions"];
12
+ const WEBPACK_RECURSIVE_INPUTS = ["development/webpack"];
13
+ const WEBPACK_CACHE_DIR = "node_modules/.cache/webpack";
14
+ const WATCH_LOG_CANDIDATES = recipeWatchLogCandidates();
15
+ function git(target, args) {
16
+ try {
17
+ return execFileSync("git", ["-C", target, ...args], {
18
+ encoding: "utf8",
19
+ stdio: ["ignore", "pipe", "ignore"]
20
+ }).trim();
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ function gitRaw(target, args) {
26
+ try {
27
+ return execFileSync("git", ["-C", target, ...args], {
28
+ encoding: "utf8",
29
+ stdio: ["ignore", "pipe", "ignore"]
30
+ });
31
+ } catch {
32
+ return "";
33
+ }
34
+ }
35
+ function addFile(hash, target, rel) {
36
+ const abs = path.join(target, rel);
37
+ const stat = fs.statSync(abs);
38
+ hash.update(rel);
39
+ hash.update(String(stat.size));
40
+ hash.update(fs.readFileSync(abs));
41
+ }
42
+ function walk(hash, target, relDir) {
43
+ const absDir = path.join(target, relDir);
44
+ if (!fs.existsSync(absDir)) return;
45
+ for (const name of fs.readdirSync(absDir).sort()) {
46
+ const rel = path.join(relDir, name);
47
+ const stat = fs.statSync(path.join(target, rel));
48
+ if (stat.isDirectory()) walk(hash, target, rel);
49
+ else if (stat.isFile() && /\.(c?m?[jt]sx?|json)$/u.test(rel)) addFile(hash, target, rel);
50
+ }
51
+ }
52
+ function webpackFingerprint(target) {
53
+ const hash = crypto.createHash("sha256");
54
+ for (const rel of WEBPACK_DIRECT_INPUTS) {
55
+ if (fs.existsSync(path.join(target, rel))) addFile(hash, target, rel);
56
+ }
57
+ for (const dir of WEBPACK_RECURSIVE_INPUTS) walk(hash, target, dir);
58
+ return { gitHead: git(target, ["rev-parse", "HEAD"]) ?? "unknown", fingerprint: hash.digest("hex") };
59
+ }
60
+ function stateDir(target) {
61
+ return recipeHarnessPath(target, "extension", "decision-state");
62
+ }
63
+ function readBaseline(target, name) {
64
+ try {
65
+ return JSON.parse(fs.readFileSync(path.join(stateDir(target), name), "utf8"));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+ function writeBaseline(target, name, value) {
71
+ const dir = stateDir(target);
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ fs.writeFileSync(path.join(dir, name), `${JSON.stringify(value, null, 2)}
74
+ `);
75
+ }
76
+ function newestMtime(target, rels) {
77
+ let newest = 0;
78
+ for (const rel of rels) {
79
+ const abs = path.join(target, rel);
80
+ if (fs.existsSync(abs)) newest = Math.max(newest, fs.statSync(abs).mtimeMs);
81
+ }
82
+ return newest;
83
+ }
84
+ function webpackCacheCheck(target) {
85
+ const cachePresent = fs.existsSync(path.join(target, WEBPACK_CACHE_DIR));
86
+ if (!cachePresent) return { cachePresent: false, status: "no-cache", hasBaseline: false };
87
+ const baseline = readBaseline(target, "webpack-cache-state.json");
88
+ if (!baseline) {
89
+ const cacheMtime = fs.statSync(path.join(target, WEBPACK_CACHE_DIR)).mtimeMs;
90
+ const poisoned = newestMtime(target, ["yarn.lock", ...INSTALL_MARKERS]) > cacheMtime;
91
+ return { cachePresent: true, status: poisoned ? "stale" : "current", hasBaseline: false };
92
+ }
93
+ const current = webpackFingerprint(target);
94
+ const status = baseline.fingerprint === current.fingerprint && baseline.gitHead === current.gitHead ? "current" : "stale";
95
+ return { cachePresent: true, status, hasBaseline: true };
96
+ }
97
+ function buildLogCheck(target, watchLog) {
98
+ const candidates = (watchLog ? [path.isAbsolute(watchLog) ? watchLog : path.join(target, watchLog)] : WATCH_LOG_CANDIDATES.map((rel) => path.join(target, rel))).filter((file) => fs.existsSync(file));
99
+ if (!candidates.length) return { status: "no-watch" };
100
+ const newestFirst = [...candidates].sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
101
+ const lines = fs.readFileSync(newestFirst[0], "utf8").split("\n");
102
+ const ERR = /Module build failed|^ERROR in |compiled with [1-9]\d* error/u;
103
+ const OK = /compiled successfully|compiled with \d+ warning|MetaMask .* compiled|Bundle end: service worker|Bundle end:.*app-init/iu;
104
+ let lastErr = -1;
105
+ let lastOk = -1;
106
+ for (let i = 0; i < lines.length; i += 1) {
107
+ if (ERR.test(lines[i])) lastErr = i;
108
+ if (OK.test(lines[i])) lastOk = i;
109
+ }
110
+ if (lastErr > lastOk) {
111
+ const excerpt = lines.slice(lastErr, lastErr + 3).join(" | ").slice(0, 400);
112
+ const staleCache = /ENOENT/u.test(excerpt) && /node_modules/u.test(excerpt);
113
+ return { status: "errors", reason: staleCache ? "stale-cache" : "build-error", excerpt };
114
+ }
115
+ if (lastOk >= 0) return { status: "ok" };
116
+ return { status: "building" };
117
+ }
118
+ function uncommittedSource(target) {
119
+ const dirs = ["ui", "app", "shared", "development"].filter((d) => fs.existsSync(path.join(target, d)));
120
+ if (!dirs.length) return [];
121
+ const SRC = /\.(ts|tsx|js|jsx|cjs|mjs|json|scss|css)$/iu;
122
+ const records = gitRaw(target, ["-c", "core.quotePath=false", "status", "--porcelain", "-z", "--", ...dirs]).split("\0").filter(Boolean);
123
+ const dirty = [];
124
+ for (const record of records) {
125
+ const file = /^.. /u.test(record) ? record.slice(3) : record;
126
+ if (SRC.test(file)) dirty.push(file);
127
+ }
128
+ return dirty;
129
+ }
130
+ function latestFileMtime(target, files) {
131
+ let latest = 0;
132
+ for (const file of files) {
133
+ const absolute = path.join(target, file);
134
+ if (!fs.existsSync(absolute)) continue;
135
+ const stat = fs.statSync(absolute);
136
+ if (stat.isFile()) latest = Math.max(latest, stat.mtimeMs);
137
+ }
138
+ return latest;
139
+ }
140
+ function latestDistMtime(target) {
141
+ const distDir = path.join(target, "dist/chrome");
142
+ let latest = 0;
143
+ function walk2(dir) {
144
+ if (!fs.existsSync(dir)) return;
145
+ for (const entry of fs.readdirSync(dir)) {
146
+ const absolute = path.join(dir, entry);
147
+ const stat = fs.statSync(absolute);
148
+ if (stat.isDirectory()) walk2(absolute);
149
+ else if (stat.isFile()) latest = Math.max(latest, stat.mtimeMs);
150
+ }
151
+ }
152
+ walk2(distDir);
153
+ return latest;
154
+ }
155
+ function distCheck(target) {
156
+ const manifestPath = path.join(target, "dist/chrome/manifest.json");
157
+ if (!fs.existsSync(manifestPath)) return { status: "no-build" };
158
+ let manifest;
159
+ try {
160
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
161
+ } catch {
162
+ return { status: "no-build" };
163
+ }
164
+ const description = typeof manifest.description === "string" ? manifest.description : "";
165
+ const match = /from git id:\s*([0-9a-f]{7,40})/iu.exec(description);
166
+ const head = (git(target, ["rev-parse", "HEAD"]) ?? "").toLowerCase();
167
+ const headShort = head ? head.slice(0, 8) : void 0;
168
+ if (!match) return { status: "unknown", head: headShort };
169
+ const distGitId = match[1].toLowerCase();
170
+ const distShort = distGitId.slice(0, 8);
171
+ if (!head) return { status: "unknown", distGitId: distShort };
172
+ if (!head.startsWith(distGitId)) {
173
+ return { status: "stale", reason: "commit-mismatch", distGitId: distShort, head: headShort };
174
+ }
175
+ const dirty = uncommittedSource(target);
176
+ if (dirty.length) {
177
+ const dirtySinceBuild = latestFileMtime(target, dirty) > latestDistMtime(target);
178
+ if (dirtySinceBuild) {
179
+ return { status: "stale", reason: "uncommitted-source", distGitId: distShort, head: headShort, modified: dirty.slice(0, 10) };
180
+ }
181
+ }
182
+ return { status: "fresh", distGitId: distShort, head: headShort };
183
+ }
184
+ async function cdpCheck(target, cdpPort) {
185
+ if (!cdpPort) return { status: "skipped" };
186
+ try {
187
+ const { checkExtensionRuntimeHealth } = await import("./runtime.js");
188
+ const report = await checkExtensionRuntimeHealth(target, cdpPort);
189
+ return report.status === "PASS" ? { status: "pass" } : { status: "fail", findings: report.findings };
190
+ } catch (error) {
191
+ return { status: "fail", findings: [error instanceof Error ? error.message : String(error)] };
192
+ }
193
+ }
194
+ function recordReadinessBaseline(target) {
195
+ recordDepsBaseline(target);
196
+ const cache = webpackFingerprint(target);
197
+ writeBaseline(target, "webpack-cache-state.json", cache);
198
+ }
199
+ async function decideExtensionReadiness(target, options = {}) {
200
+ const resolved = path.resolve(target);
201
+ if (options.record) recordReadinessBaseline(resolved);
202
+ const deps = depsCheck(resolved);
203
+ const webpackCache = webpackCacheCheck(resolved);
204
+ const buildLog = buildLogCheck(resolved, options.watchLog);
205
+ const dist = distCheck(resolved);
206
+ const cdp = await cdpCheck(resolved, options.cdpPort);
207
+ const checks = { deps, webpackCache, buildLog, dist, cdp };
208
+ const install = [{ id: "yarn-install", argv: ["yarn", "install", "--immutable"], cwd: resolved }];
209
+ const relaunch = [{ id: "relaunch-browser" }];
210
+ const cacheStale = buildLog.reason === "stale-cache";
211
+ const rules = [
212
+ {
213
+ when: deps.status === "missing",
214
+ decision: "install",
215
+ reasonCode: "deps-missing",
216
+ reasons: ["Dependencies are not installed (no yarn install-state markers)."],
217
+ actions: install
218
+ },
219
+ {
220
+ when: deps.status === "stale",
221
+ decision: "install",
222
+ reasonCode: "deps-stale",
223
+ reasons: ["package.json/yarn.lock changed since the recorded install."],
224
+ actions: install
225
+ },
226
+ {
227
+ when: buildLog.status === "errors",
228
+ decision: "build",
229
+ clean: cacheStale,
230
+ reasonCode: cacheStale ? "webpack-cache-stale" : "build-errors",
231
+ reasons: [
232
+ cacheStale ? "Webpack is failing on a poisoned cache (ENOENT on a deduped module)." : "Webpack build has errors; fix the source/build before validating.",
233
+ ...buildLog.excerpt ? [buildLog.excerpt] : []
234
+ ],
235
+ actions: buildAction(resolved, cacheStale)
236
+ },
237
+ {
238
+ when: webpackCache.status === "stale",
239
+ decision: "build",
240
+ clean: true,
241
+ reasonCode: "webpack-cache-stale",
242
+ reasons: ["Webpack build inputs changed since the cache was recorded; clear cache and rebuild."],
243
+ actions: buildAction(resolved, true)
244
+ },
245
+ {
246
+ when: dist.status === "no-build",
247
+ decision: "build",
248
+ reasonCode: "dist-missing",
249
+ reasons: ["No dist/chrome build present."],
250
+ actions: buildAction(resolved, false)
251
+ },
252
+ {
253
+ when: dist.status === "stale",
254
+ decision: "build",
255
+ reasonCode: "dist-stale",
256
+ reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
257
+ actions: buildAction(resolved, false)
258
+ },
259
+ {
260
+ when: cdp.status === "pass",
261
+ decision: "ready",
262
+ reasonCode: "healthy",
263
+ reasons: ["Build is fresh and the extension runtime is healthy over CDP."],
264
+ actions: []
265
+ },
266
+ {
267
+ when: cdp.status === "fail",
268
+ decision: "relaunch",
269
+ reasonCode: "runtime-unhealthy",
270
+ reasons: [
271
+ "Build is fresh but the live extension is unhealthy; relaunch the browser.",
272
+ ...cdp.findings?.slice(0, 3) ?? []
273
+ ],
274
+ actions: relaunch
275
+ }
276
+ ];
277
+ const fallback = {
278
+ decision: "relaunch",
279
+ reasonCode: "cdp-unknown",
280
+ reasons: ["Build is fresh; browser liveness unverified (pass --cdp-port to confirm `ready`)."],
281
+ actions: relaunch
282
+ };
283
+ const core = rules.find((rule) => rule.when) ?? fallback;
284
+ return {
285
+ schemaVersion: 1,
286
+ adapter: "extension",
287
+ target: resolved,
288
+ decision: core.decision,
289
+ clean: core.clean ?? false,
290
+ reasonCode: core.reasonCode,
291
+ reasons: core.reasons,
292
+ checks,
293
+ actions: core.actions
294
+ };
295
+ }
296
+ function buildAction(target, clean) {
297
+ const actions = [];
298
+ if (clean) actions.push({ id: "clear-webpack-cache", paths: [WEBPACK_CACHE_DIR] });
299
+ actions.push({ id: "start-webpack-watch", argv: ["yarn", "start"], cwd: target });
300
+ return actions;
301
+ }
302
+ export {
303
+ decideExtensionReadiness,
304
+ recordReadinessBaseline
305
+ };