@deeeed/metamask-harness 0.3.9 → 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 (58) hide show
  1. package/CHANGELOG.md +45 -1
  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/ADAPTER-SURFACE.md +119 -0
  42. package/docs/CLI-SPEC.md +26 -3
  43. package/docs/UX-PRINCIPLES.md +3 -0
  44. package/package.json +10 -2
  45. package/src/adapters/core/surface.ts +71 -0
  46. package/src/adapters/extension/surface.ts +88 -0
  47. package/src/adapters/mobile/provision.ts +594 -0
  48. package/src/adapters/mobile/surface.ts +71 -0
  49. package/src/adapters/slot-ports.ts +165 -0
  50. package/src/adapters/surface.ts +117 -0
  51. package/src/cli-commands.ts +1 -1
  52. package/src/cli.ts +239 -49
  53. package/src/commands/debug.ts +3 -1
  54. package/src/commands/fixtures.ts +13 -8
  55. package/src/commands/launch.ts +7 -156
  56. package/src/commands/logs.ts +29 -13
  57. package/src/harness.ts +140 -3
  58. package/src/mm-harness-cli.ts +71 -18
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
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
+
14
+ ## 0.4.0 - 2026-07-04
15
+
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).
17
+
18
+ ### Added
19
+ - **AdapterSurface** — one per-platform surface behind a common interface (`src/adapters/surface.ts`: resolveSlotPorts, runtimeStatus, devServer, logSources, hints); doctor/launch/stop/logs/debug/fixtures are surface-backed, so no verb hand-rolls platform branching. Contract: `docs/ADAPTER-SURFACE.md` (never-branch rule, add-a-platform checklist, migration table).
20
+ - **Platform-adaptive `stop` on every adapter** — extension: kills this checkout's webpack watcher (pid file + orphan scan, slot-scoped) and closes its tail window; mobile: port-scoped Metro; core: headless teaching error. Idempotent, outcome-stating summaries.
21
+ - **Doctor reports live runtime state on every adapter** — mobile Metro, extension webpack (via the extension readiness decision), core deps-only — one uniform shape, adapter-scoped colored context rows.
22
+ - **`logs` is multi-source per adapter** — sources and the `--source` default come from the adapter's logSources (extension: webpack/watcher/rebuild/app).
23
+ - **Context-aware examples and tier-visible flows** — `call` usage/help examples use the detected adapter's real manifest actions; `flows` prints a precedence legend decoding each source into personal > team > canonical.
24
+
25
+ ### Changed
26
+ - **BREAKING (agent contract)**: `doctor --json` `runtime.metro` (string) is now `runtime.devServer` `{label, status}` and is absent for core.
27
+
28
+ ### Feature wave recap (0.3.6-0.3.9)
29
+ - `mm-harness stop`; live-streamed mobile AND extension launches with upfront intent lines; presence-authoritative readiness (no setup-on-every-launch) with the dev-client running probe; slot-context-first device/port resolution; UTF-8 locale for pod spawns; DEV OVERRIDE banner + SLOT line + colored help; `mmdev-harness` completions; hermetic tmux-stubbed contract suite (now gating CI) + ESLint in `npm run check`; `docs/UX-PRINCIPLES.md` interaction contract.
30
+
3
31
  ## 0.3.9 - 2026-07-04
4
32
 
5
33
  ### Fixed
@@ -17,7 +45,15 @@
17
45
 
18
46
  ## Unreleased
19
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
+
20
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.
21
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.
22
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.
23
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.
@@ -26,11 +62,19 @@
26
62
  - **Metro log-tail window no longer leaks into an unrelated tmux session** — running `start-metro` outside a tmux client fell back to `tmux display-message` (the last-attached session, e.g. the user's own) and created its `metro-<port>` window — with a `tail -F` — there, outliving the run. The viewer now opens a window only in a session that belongs to this run (a named slot session that exists, or the current session when actually inside tmux) and otherwise skips it; Metro still runs detached to the log. Extracted to `adapters/mobile/lib/tmux-viewer.sh` so the rule is unit-tested; contract tests stub tmux hermetically so no suite run can create a real window.
27
63
 
28
64
  ### Added
29
- - **`mm-harness stop`**stop the port-scoped Metro dev server this mobile checkout owns and close its log-tail window (idempotent; extension/core are slot-managed and get a teaching error). Slot-context-aware port resolution.
65
+ - **Adapter surface — one seam for per-platform behavior**`src/adapters/surface.ts` defines `AdapterSurface` + a `getAdapterSurface(adapter)` registry that owns the behavior commands used to hand-roll with `if (adapter === …)` ladders (slot-port resolution, read-only runtime status, dev-server describe/stop, log sources, and platform-phrased Next: hints), with thin `mobile`/`extension`/`core` implementations that delegate to the existing readiness/port plumbing (re-homed to `src/adapters/slot-ports.ts`). `doctor`, `launch`, `stop`, `logs`, `debug`, and `fixtures` now resolve platform behavior through the surface instead of branching. This closes the platform-blind class of bug (a platform handled in one command but forgotten in another). Documented in `docs/ADAPTER-SURFACE.md`; enforces `docs/UX-PRINCIPLES.md` principle 1.
66
+ - **`doctor` reports a runtime section for every adapter** — the live runtime readiness line (decision + reasons, deps currency, dev server) now renders for extension (webpack watcher via `decideExtensionReadiness`) and core (deps-presence-only), not just mobile; the render is one uniform shape sourced from the surface.
67
+ - **`mm-harness stop` is adaptive per platform** — mobile stops its port-scoped Metro and closes the log-tail window; extension stops the checkout's webpack watcher (pid file + orphan ps-scan, slot-scoped) and closes its tail window; core teaches the headless path. All idempotent (nothing-to-stop is success), with a uniform summary and `--json` envelope.
68
+ - **`logs` resolves dev-server log sources through the surface** — extension now recognizes the webpack, watcher, and rebuild logs (ordered), not only `webpack.log`; each platform's "nothing running" teaches its own launch hint. `--source` default and valid set come from the adapter's own log sources (extension defaults to `webpack`, not `metro`), and core teaches with platform-neutral wording (no Metro vocabulary). `--source <label>` now prefers that dev-server log.
69
+ - **`call` with no action gives a context-aware example** — the usage error shows the DETECTED adapter and a REAL action from its manifest (a wallet action where one exists, else the universal `command`) plus the scoped `mm-harness actions --adapter <detected>` discovery hint. The static `call --help` example now uses real actions (`ensure_unlocked` / `command`) and points at `mm-harness actions`, instead of a hardcoded `call unlock --adapter extension` naming a non-existent `metamask.wallet.unlock`.
70
+ - **`flows` surfaces the resolution tier per source** — `flows` prints a precedence legend mapping each resolved library source to its tier (`personal` / `team` / `canonical`), derived from the actual resolution, so the engine's `source=<name>` per flow is decodable and LOCAL libraries read as first-class; the tier is also named in the recipe-library-unavailable teaching error.
71
+ - **`stop --help` matches the adaptive behavior** — the help text now describes mobile Metro / extension webpack watcher / core headless, instead of the stale "Metro dev server … extension/core: teaching error".
30
72
  - **Slot-context device/port detection** — `resolve-farmslot-ports.sh` reads `agentic-runtime.json` (the mapping the orchestrator's prepare wrote into the checkout) first, so device/port resolution survives pool renames that break any naming formula.
31
73
  - **CLI help surface** — grouped/colored `mm-harness` help, a `DEV OVERRIDE ACTIVE` banner when `MM_HARNESS_BIN` is set, and a `SLOT` line when the checkout is a prepared slot; `mmdev-harness` completion alias.
32
74
 
33
75
  ### Changed
76
+ - **`doctor --json` runtime dev-server shape is normalized** — the mobile-only `runtime.metro` string is replaced by a platform-labelled `runtime.devServer: { label, status }` (`metro` for mobile, `webpack` for extension; absent for headless core), so agents read one shape across platforms.
77
+ - **`yarn check` runs ESLint** — flat-config ESLint (typescript-eslint recommended on `src/**/*.ts`, `@eslint/js` recommended on the `.mjs`/`.cjs` library) runs after typecheck and comment-hygiene. Dev-only (not in the published `files`); the baseline is zero-warning by config scoping.
34
78
  - **Deps install uses a scoped `setup:expo` variant when native isn't wanted** — `adapters/mobile/yarn-setup.sh` gains `--mode full|expo`. `expo` runs `yarn setup:expo --no-build-ios --no-build-android` (JS deps + prebuild, no native — native builds come from `open-device.sh`, never doubled); the full `yarn setup` runs only for a native build (`launch --build`). This is also the standalone Metro-only remedy taught by the fast-tier deps block.
35
79
  - **`mm-harness runtime-decision --adapter mobile` accepts `--preflight-mode`** — surfaces the fast (presence, blocks on a genuine gap) vs full (installs) decision for inspection and callers.
36
80
 
@@ -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
+ };