@deeeed/metamask-harness 0.2.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 +161 -0
- package/README.md +140 -0
- package/bin/mm-harness +99 -0
- package/docs/CHEATSHEET.md +61 -0
- package/docs/CLI-SPEC.md +915 -0
- package/docs/MENTAL-MODEL.md +295 -0
- package/docs/architecture.md +367 -0
- package/docs/extension-runtime-commands.md +60 -0
- package/docs/harness-cli.md +43 -0
- package/docs/live-adapter-contract.md +188 -0
- package/docs/package-boundaries.md +47 -0
- package/docs/perps-flow-catalog.md +235 -0
- package/docs/recipe-libraries.md +95 -0
- package/docs/runtime-file-conventions.md +36 -0
- package/library/actions/core/perps/_controller.mjs +727 -0
- package/library/actions/core/perps/assert_orders.mjs +53 -0
- package/library/actions/core/perps/assert_positions.mjs +52 -0
- package/library/actions/core/perps/close_orders.mjs +97 -0
- package/library/actions/core/perps/close_positions.mjs +118 -0
- package/library/actions/core/perps/ensure_orders.mjs +40 -0
- package/library/actions/core/perps/ensure_positions.mjs +37 -0
- package/library/actions/core/perps/place_order.mjs +201 -0
- package/library/actions/core/perps/read_account.mjs +30 -0
- package/library/actions/core/perps/read_orders.mjs +27 -0
- package/library/actions/core/perps/read_positions.mjs +27 -0
- package/library/actions/core/perps/start_state.mjs +92 -0
- package/library/actions/core/perps/teardown_state.mjs +86 -0
- package/library/actions/extension/perps/assert_orders.mjs +11 -0
- package/library/actions/extension/perps/assert_positions.mjs +11 -0
- package/library/actions/extension/perps/close_orders.mjs +8 -0
- package/library/actions/extension/perps/close_positions.mjs +8 -0
- package/library/actions/extension/perps/ensure_orders.mjs +4 -0
- package/library/actions/extension/perps/ensure_positions.mjs +4 -0
- package/library/actions/extension/perps/perps.mjs +730 -0
- package/library/actions/extension/perps/place_order.mjs +7 -0
- package/library/actions/extension/perps/read_orders.mjs +4 -0
- package/library/actions/extension/perps/read_positions.mjs +3 -0
- package/library/actions/extension/platform/cdp.mjs +541 -0
- package/library/actions/extension/ui/navigate.mjs +44 -0
- package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
- package/library/actions/extension/wallet/read_state.mjs +27 -0
- package/library/actions/extension/wallet/select_account.mjs +48 -0
- package/library/actions/extension/wallet/setup.mjs +35 -0
- package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
- package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
- package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
- package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
- package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
- package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
- package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
- package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
- package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
- package/library/actions/mobile/perps/assert_orders.mjs +11 -0
- package/library/actions/mobile/perps/assert_positions.mjs +11 -0
- package/library/actions/mobile/perps/close_orders.mjs +8 -0
- package/library/actions/mobile/perps/close_positions.mjs +8 -0
- package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
- package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
- package/library/actions/mobile/perps/perps.mjs +709 -0
- package/library/actions/mobile/perps/place_order.mjs +7 -0
- package/library/actions/mobile/perps/read_orders.mjs +4 -0
- package/library/actions/mobile/perps/read_positions.mjs +3 -0
- package/library/actions/mobile/platform/bridge.mjs +283 -0
- package/library/actions/mobile/ui/navigate.mjs +38 -0
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
- package/library/actions/mobile/wallet/home.mjs +35 -0
- package/library/actions/mobile/wallet/read_state.mjs +40 -0
- package/library/actions/mobile/wallet/select_account.mjs +48 -0
- package/library/actions/mobile/wallet/setup.mjs +220 -0
- package/library/flows/perps.flows.json +64 -0
- package/library/library.json +7 -0
- package/library/manifests/core.action-manifest.json +1282 -0
- package/library/manifests/extension.action-manifest.json +1749 -0
- package/library/manifests/mobile.action-manifest.json +1753 -0
- package/library/recipes/action-validation.extension.recipe.json +417 -0
- package/library/recipes/action-validation.mobile.recipe.json +422 -0
- package/library/recipes/order-lifecycle.core.recipe.json +78 -0
- package/library/recipes/perps-lifecycle.recipe.json +194 -0
- package/library/recipes/read-markets.core.recipe.json +38 -0
- package/library/recipes/smoke.extension.recipe.json +31 -0
- package/library/recipes/smoke.mobile.recipe.json +31 -0
- package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
- package/orchestration/compat-overlays/README.md +19 -0
- package/orchestration/compat-overlays/mobile/README.md +13 -0
- package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
- package/orchestration/core/cleanup.sh +37 -0
- package/orchestration/core/inject.sh +154 -0
- package/orchestration/doctor.mjs +72 -0
- package/orchestration/extension/cleanup.mjs +60 -0
- package/orchestration/extension/console-tail.mjs +228 -0
- package/orchestration/extension/ensure-browser.sh +416 -0
- package/orchestration/extension/ensure-ready.ts +185 -0
- package/orchestration/extension/extension-id.ts +107 -0
- package/orchestration/extension/inject.mjs +266 -0
- package/orchestration/extension/launch-browser.cjs +216 -0
- package/orchestration/extension/launch.sh +175 -0
- package/orchestration/extension/live.sh +320 -0
- package/orchestration/extension/pin-remote-flags.cjs +45 -0
- package/orchestration/extension/readiness.mjs +414 -0
- package/orchestration/extension/refresh-build.sh +190 -0
- package/orchestration/extension/runtime-decision.ts +445 -0
- package/orchestration/extension/runtime.ts +407 -0
- package/orchestration/extension/seed-fixture.sh +177 -0
- package/orchestration/extension/sidepanel-toggle.sh +291 -0
- package/orchestration/extension/snapshot-dist.sh +84 -0
- package/orchestration/extension/start-watch.sh +339 -0
- package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
- package/orchestration/lib/activate-repo-node.sh +144 -0
- package/orchestration/lib/cli-color.mjs +84 -0
- package/orchestration/lib/cli-commands.mjs +243 -0
- package/orchestration/lib/cli-home.mjs +354 -0
- package/orchestration/lib/cli-ux.sh +252 -0
- package/orchestration/lib/cli-version.mjs +123 -0
- package/orchestration/lib/ensure-runner-deps.sh +56 -0
- package/orchestration/lib/harness-path.sh +55 -0
- package/orchestration/lib/hash-helpers.sh +44 -0
- package/orchestration/lib/json-field.sh +23 -0
- package/orchestration/lib/log-tui.mjs +304 -0
- package/orchestration/lib/open-debug.mjs +317 -0
- package/orchestration/lib/path-defaults.json +4 -0
- package/orchestration/lib/progress.mjs +107 -0
- package/orchestration/lib/recipe-paths.mjs +26 -0
- package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
- package/orchestration/manifest.json +358 -0
- package/orchestration/mobile/cleanup.sh +192 -0
- package/orchestration/mobile/deps-markers.ts +21 -0
- package/orchestration/mobile/inject.sh +681 -0
- package/orchestration/mobile/launch.sh +137 -0
- package/orchestration/mobile/live.sh +125 -0
- package/orchestration/mobile/runtime-decision.ts +292 -0
- package/orchestration/porcelain/metamask-recipe +99 -0
- package/orchestration/porcelain/mm-recipe +1591 -0
- package/orchestration/porcelain/mme-recipe +1181 -0
- package/package.json +59 -0
- package/runner/extension/verify.sh +511 -0
- package/runner/mobile/verify.sh +501 -0
- package/runner/src/adapters.ts +601 -0
- package/runner/src/cli.ts +1820 -0
- package/runner/src/commands/debug.ts +44 -0
- package/runner/src/commands/fixtures.ts +99 -0
- package/runner/src/commands/launch.ts +397 -0
- package/runner/src/commands/logs.ts +60 -0
- package/runner/src/commands/shared.ts +138 -0
- package/runner/src/completions-cache.ts +86 -0
- package/runner/src/doctor.ts +203 -0
- package/runner/src/harness.ts +516 -0
- package/runner/src/heal-bounds.ts +179 -0
- package/runner/src/index.ts +6 -0
- package/runner/src/live-adapter-contract.ts +274 -0
- package/runner/src/manifest.ts +47 -0
- package/runner/src/mm-harness-cli.ts +488 -0
- package/runner/src/paths.ts +198 -0
- package/runner/src/recording-target.ts +147 -0
- package/runner/src/run-recording.ts +329 -0
- package/runner/src/runner.ts +108 -0
- package/runner/src/types.ts +57 -0
- package/scripts/completions.sh +125 -0
- package/scripts/install-completions.sh +62 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// runtime-decision.ts — cache/deps baseline + readiness decision.
|
|
2
|
+
// runner/extension/extension-runtime-decision.ts) App/instance control.
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
depsCheck,
|
|
10
|
+
INSTALL_MARKERS,
|
|
11
|
+
recordDepsBaseline,
|
|
12
|
+
type DepsCheck,
|
|
13
|
+
} from '@farmslot/recipe-harness/runtime/deps-readiness';
|
|
14
|
+
import { recipeWatchLogCandidates } from '../../runner/src/paths.ts';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Deterministic runtime-readiness decision for the MetaMask Extension.
|
|
18
|
+
*
|
|
19
|
+
* WHY THIS EXISTS — the deterministic layer for agents.
|
|
20
|
+
* AI agents repeatedly burned tokens flailing when the extension runtime was
|
|
21
|
+
* not ready: a poisoned webpack cache (ENOENT on a deduped module) made every
|
|
22
|
+
* rebuild fail; a stale dist rendered the home page but threw on Perps; a dead
|
|
23
|
+
* browser left CDP empty. Each agent guessed a different recovery and often did
|
|
24
|
+
* a full rebuild when a relaunch would do. This module replaces guessing with a
|
|
25
|
+
* single, side-effect-free answer: given a checkout (and optionally a live CDP
|
|
26
|
+
* port), it returns the ONE cheapest action that makes the runtime ready —
|
|
27
|
+
* `install` < `build` (optionally `clean`) < `relaunch` < `ready` — plus the
|
|
28
|
+
* machine-consumable `actions[]` a host runs. The host executes; the runner
|
|
29
|
+
* only decides.
|
|
30
|
+
*
|
|
31
|
+
* This is the single source of truth for "what does the extension runtime need"
|
|
32
|
+
* so wrappers do not hand-roll dist-freshness/build-health probes.
|
|
33
|
+
*
|
|
34
|
+
* SIGNALS (pure file/git, plus an optional live CDP probe):
|
|
35
|
+
* - deps: install markers present + content fingerprint vs recorded baseline
|
|
36
|
+
* - webpackCache: cache present + superset fingerprint vs recorded baseline (poisoning)
|
|
37
|
+
* - buildLog: webpack watch log → ok | building | errors (incl. stale-cache ENOENT)
|
|
38
|
+
* - dist: manifest git-id vs HEAD + uncommitted source → fresh | stale | unknown
|
|
39
|
+
* - cdp: live extension health (ONLY when --cdp-port is passed)
|
|
40
|
+
*
|
|
41
|
+
* The deps/cache fingerprints use the host preflight algorithm so the runner is
|
|
42
|
+
* the reference implementation.
|
|
43
|
+
* checkExtensionRuntimeHealth is imported LAZILY (it loads the recipe harness
|
|
44
|
+
* at module scope and FAILs without a live CDP) so the no-browser decision path
|
|
45
|
+
* never touches it.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export type ReadinessDecision =
|
|
49
|
+
| 'install'
|
|
50
|
+
| 'build'
|
|
51
|
+
| 'relaunch'
|
|
52
|
+
| 'ready'
|
|
53
|
+
| 'blocked'
|
|
54
|
+
| 'unknown';
|
|
55
|
+
|
|
56
|
+
export interface RuntimeDecisionAction {
|
|
57
|
+
/** Stable id a host branches on, e.g. 'yarn-install', 'clear-webpack-cache'. */
|
|
58
|
+
id: string;
|
|
59
|
+
/** Argv to exec (host runs verbatim — never a shell string to re-parse). */
|
|
60
|
+
argv?: string[];
|
|
61
|
+
/** Working directory for argv. */
|
|
62
|
+
cwd?: string;
|
|
63
|
+
/** Filesystem paths (relative to target) a host should remove. */
|
|
64
|
+
paths?: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RuntimeDecisionReport {
|
|
68
|
+
schemaVersion: 1;
|
|
69
|
+
adapter: 'extension';
|
|
70
|
+
target: string;
|
|
71
|
+
decision: ReadinessDecision;
|
|
72
|
+
/** true only when the build must clear the webpack cache first. */
|
|
73
|
+
clean: boolean;
|
|
74
|
+
reasonCode: string;
|
|
75
|
+
reasons: string[];
|
|
76
|
+
checks: {
|
|
77
|
+
deps: DepsCheck;
|
|
78
|
+
webpackCache: WebpackCacheCheck;
|
|
79
|
+
buildLog: BuildLogCheck;
|
|
80
|
+
dist: DistCheck;
|
|
81
|
+
cdp: CdpCheck;
|
|
82
|
+
};
|
|
83
|
+
actions: RuntimeDecisionAction[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RuntimeDecisionOptions {
|
|
87
|
+
cdpPort?: number;
|
|
88
|
+
/** Override the webpack watch-log path probed for build health. */
|
|
89
|
+
watchLog?: string;
|
|
90
|
+
/** Record the current deps/cache fingerprints as the new baseline and return. */
|
|
91
|
+
record?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface WebpackCacheCheck {
|
|
95
|
+
cachePresent: boolean;
|
|
96
|
+
status: 'current' | 'stale' | 'no-cache';
|
|
97
|
+
hasBaseline: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface BuildLogCheck {
|
|
101
|
+
status: 'ok' | 'building' | 'errors' | 'no-watch';
|
|
102
|
+
reason?: 'stale-cache' | 'build-error';
|
|
103
|
+
excerpt?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface DistCheck {
|
|
107
|
+
status: 'fresh' | 'stale' | 'unknown' | 'no-build';
|
|
108
|
+
distGitId?: string;
|
|
109
|
+
head?: string;
|
|
110
|
+
reason?: 'commit-mismatch' | 'uncommitted-source';
|
|
111
|
+
modified?: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface CdpCheck {
|
|
115
|
+
status: 'pass' | 'fail' | 'skipped';
|
|
116
|
+
findings?: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── fingerprint inputs (host preflight parity) ───────────────────────────────
|
|
120
|
+
const WEBPACK_DIRECT_INPUTS = ['package.json', 'yarn.lock', '.yarnrc.yml', '.tool-versions'];
|
|
121
|
+
const WEBPACK_RECURSIVE_INPUTS = ['development/webpack'];
|
|
122
|
+
const WEBPACK_CACHE_DIR = 'node_modules/.cache/webpack';
|
|
123
|
+
const WATCH_LOG_CANDIDATES = recipeWatchLogCandidates();
|
|
124
|
+
|
|
125
|
+
// ── git helpers ───────────────────────────────────────────────────────────────
|
|
126
|
+
function git(target: string, args: string[]): string | null {
|
|
127
|
+
try {
|
|
128
|
+
return execFileSync('git', ['-C', target, ...args], {
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
131
|
+
}).trim();
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function gitRaw(target: string, args: string[]): string {
|
|
138
|
+
try {
|
|
139
|
+
return execFileSync('git', ['-C', target, ...args], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── content fingerprints (preflight algorithm) ───────────────────────────────
|
|
149
|
+
function addFile(hash: crypto.Hash, target: string, rel: string): void {
|
|
150
|
+
const abs = path.join(target, rel);
|
|
151
|
+
const stat = fs.statSync(abs);
|
|
152
|
+
hash.update(rel);
|
|
153
|
+
hash.update(String(stat.size));
|
|
154
|
+
hash.update(fs.readFileSync(abs));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function walk(hash: crypto.Hash, target: string, relDir: string): void {
|
|
158
|
+
const absDir = path.join(target, relDir);
|
|
159
|
+
if (!fs.existsSync(absDir)) return;
|
|
160
|
+
for (const name of fs.readdirSync(absDir).sort()) {
|
|
161
|
+
const rel = path.join(relDir, name);
|
|
162
|
+
const stat = fs.statSync(path.join(target, rel));
|
|
163
|
+
if (stat.isDirectory()) walk(hash, target, rel);
|
|
164
|
+
else if (stat.isFile() && /\.(c?m?[jt]sx?|json)$/u.test(rel)) addFile(hash, target, rel);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Webpack-cache fingerprint — host preflight parity: content hash of the
|
|
170
|
+
* direct inputs + a recursive walk of development/webpack, pinned to gitHead.
|
|
171
|
+
* Node binary/version are intentionally captured via .tool-versions content
|
|
172
|
+
* (a direct input) rather than the runner's own process.version, because the
|
|
173
|
+
* runner is not the build process and its Node may differ from the host's.
|
|
174
|
+
*/
|
|
175
|
+
function webpackFingerprint(target: string): { gitHead: string; fingerprint: string } {
|
|
176
|
+
const hash = crypto.createHash('sha256');
|
|
177
|
+
for (const rel of WEBPACK_DIRECT_INPUTS) {
|
|
178
|
+
if (fs.existsSync(path.join(target, rel))) addFile(hash, target, rel);
|
|
179
|
+
}
|
|
180
|
+
for (const dir of WEBPACK_RECURSIVE_INPUTS) walk(hash, target, dir);
|
|
181
|
+
return { gitHead: git(target, ['rev-parse', 'HEAD']) ?? 'unknown', fingerprint: hash.digest('hex') };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── per-checkout baseline store (machine-local; never written into the repo) ──
|
|
185
|
+
function stateDir(target: string): string {
|
|
186
|
+
const key = crypto.createHash('sha1').update(path.resolve(target)).digest('hex').slice(0, 16);
|
|
187
|
+
return path.join(os.tmpdir(), 'metamask-recipe-decision', key);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readBaseline(target: string, name: string): Record<string, unknown> | null {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(fs.readFileSync(path.join(stateDir(target), name), 'utf8'));
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function writeBaseline(target: string, name: string, value: Record<string, unknown>): void {
|
|
199
|
+
const dir = stateDir(target);
|
|
200
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
201
|
+
fs.writeFileSync(path.join(dir, name), `${JSON.stringify(value, null, 2)}\n`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── signal: dependency install state ─────────────────────────────────────────
|
|
205
|
+
function newestMtime(target: string, rels: string[]): number {
|
|
206
|
+
let newest = 0;
|
|
207
|
+
for (const rel of rels) {
|
|
208
|
+
const abs = path.join(target, rel);
|
|
209
|
+
if (fs.existsSync(abs)) newest = Math.max(newest, fs.statSync(abs).mtimeMs);
|
|
210
|
+
}
|
|
211
|
+
return newest;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── signal: webpack cache (poisoning) ────────────────────────────────────────
|
|
215
|
+
function webpackCacheCheck(target: string): WebpackCacheCheck {
|
|
216
|
+
const cachePresent = fs.existsSync(path.join(target, WEBPACK_CACHE_DIR));
|
|
217
|
+
if (!cachePresent) return { cachePresent: false, status: 'no-cache', hasBaseline: false };
|
|
218
|
+
const baseline = readBaseline(target, 'webpack-cache-state.json');
|
|
219
|
+
if (!baseline) {
|
|
220
|
+
// Cold start (no recorded baseline). Fall back to mtime: a cache poisoned by
|
|
221
|
+
// a post-install dedup (ENOENT on a removed nested module) is older than the
|
|
222
|
+
// install markers. This subsumes the wrapper's former inline mtime self-heal,
|
|
223
|
+
// so the cache-clear decision lives in one place. `--record` then upgrades
|
|
224
|
+
// this to the precise content fingerprint.
|
|
225
|
+
const cacheMtime = fs.statSync(path.join(target, WEBPACK_CACHE_DIR)).mtimeMs;
|
|
226
|
+
const poisoned = newestMtime(target, ['yarn.lock', ...INSTALL_MARKERS]) > cacheMtime;
|
|
227
|
+
return { cachePresent: true, status: poisoned ? 'stale' : 'current', hasBaseline: false };
|
|
228
|
+
}
|
|
229
|
+
const current = webpackFingerprint(target);
|
|
230
|
+
const status =
|
|
231
|
+
baseline.fingerprint === current.fingerprint && baseline.gitHead === current.gitHead
|
|
232
|
+
? 'current'
|
|
233
|
+
: 'stale';
|
|
234
|
+
return { cachePresent: true, status, hasBaseline: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── signal: webpack watch-log health ─────────────────────────────────────────
|
|
238
|
+
function buildLogCheck(target: string, watchLog?: string): BuildLogCheck {
|
|
239
|
+
const candidates = (
|
|
240
|
+
watchLog
|
|
241
|
+
? [path.isAbsolute(watchLog) ? watchLog : path.join(target, watchLog)]
|
|
242
|
+
: WATCH_LOG_CANDIDATES.map((rel) => path.join(target, rel))
|
|
243
|
+
).filter((file) => fs.existsSync(file));
|
|
244
|
+
if (!candidates.length) return { status: 'no-watch' };
|
|
245
|
+
const newestFirst = [...candidates].sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
246
|
+
const lines = fs.readFileSync(newestFirst[0], 'utf8').split('\n');
|
|
247
|
+
const ERR = /Module build failed|^ERROR in |compiled with [1-9]\d* error/u;
|
|
248
|
+
const OK = /compiled successfully|compiled with \d+ warning|MetaMask .* compiled|Bundle end: service worker|Bundle end:.*app-init/iu;
|
|
249
|
+
let lastErr = -1;
|
|
250
|
+
let lastOk = -1;
|
|
251
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
252
|
+
if (ERR.test(lines[i])) lastErr = i;
|
|
253
|
+
if (OK.test(lines[i])) lastOk = i;
|
|
254
|
+
}
|
|
255
|
+
if (lastErr > lastOk) {
|
|
256
|
+
const excerpt = lines.slice(lastErr, lastErr + 3).join(' | ').slice(0, 400);
|
|
257
|
+
const staleCache = /ENOENT/u.test(excerpt) && /node_modules/u.test(excerpt);
|
|
258
|
+
return { status: 'errors', reason: staleCache ? 'stale-cache' : 'build-error', excerpt };
|
|
259
|
+
}
|
|
260
|
+
if (lastOk >= 0) return { status: 'ok' };
|
|
261
|
+
return { status: 'building' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Tracked source (ui/app/shared/development) changed since the build. -z gives
|
|
265
|
+
// NUL-separated unquoted paths; mtime is unreliable here (checkout/rsync rewrite it).
|
|
266
|
+
function uncommittedSource(target: string): string[] {
|
|
267
|
+
const dirs = ['ui', 'app', 'shared', 'development'].filter((d) => fs.existsSync(path.join(target, d)));
|
|
268
|
+
if (!dirs.length) return [];
|
|
269
|
+
const SRC = /\.(ts|tsx|js|jsx|cjs|mjs|json|scss|css)$/iu;
|
|
270
|
+
const records = gitRaw(target, ['-c', 'core.quotePath=false', 'status', '--porcelain', '-z', '--', ...dirs])
|
|
271
|
+
.split('\0')
|
|
272
|
+
.filter(Boolean);
|
|
273
|
+
const dirty: string[] = [];
|
|
274
|
+
for (const record of records) {
|
|
275
|
+
const file = /^.. /u.test(record) ? record.slice(3) : record;
|
|
276
|
+
if (SRC.test(file)) dirty.push(file);
|
|
277
|
+
}
|
|
278
|
+
return dirty;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function latestFileMtime(target: string, files: string[]): number {
|
|
282
|
+
let latest = 0;
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
const absolute = path.join(target, file);
|
|
285
|
+
if (!fs.existsSync(absolute)) continue;
|
|
286
|
+
const stat = fs.statSync(absolute);
|
|
287
|
+
if (stat.isFile()) latest = Math.max(latest, stat.mtimeMs);
|
|
288
|
+
}
|
|
289
|
+
return latest;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function latestDistMtime(target: string): number {
|
|
293
|
+
const distDir = path.join(target, 'dist/chrome');
|
|
294
|
+
let latest = 0;
|
|
295
|
+
function walk(dir: string): void {
|
|
296
|
+
if (!fs.existsSync(dir)) return;
|
|
297
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
298
|
+
const absolute = path.join(dir, entry);
|
|
299
|
+
const stat = fs.statSync(absolute);
|
|
300
|
+
if (stat.isDirectory()) walk(absolute);
|
|
301
|
+
else if (stat.isFile()) latest = Math.max(latest, stat.mtimeMs);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
walk(distDir);
|
|
305
|
+
return latest;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── signal: dist freshness ───────────────────────────────────────────────────
|
|
309
|
+
function distCheck(target: string): DistCheck {
|
|
310
|
+
const manifestPath = path.join(target, 'dist/chrome/manifest.json');
|
|
311
|
+
if (!fs.existsSync(manifestPath)) return { status: 'no-build' };
|
|
312
|
+
let manifest: { description?: unknown };
|
|
313
|
+
try {
|
|
314
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
315
|
+
} catch {
|
|
316
|
+
return { status: 'no-build' };
|
|
317
|
+
}
|
|
318
|
+
const description = typeof manifest.description === 'string' ? manifest.description : '';
|
|
319
|
+
const match = /from git id:\s*([0-9a-f]{7,40})/iu.exec(description);
|
|
320
|
+
const head = (git(target, ['rev-parse', 'HEAD']) ?? '').toLowerCase();
|
|
321
|
+
const headShort = head ? head.slice(0, 8) : undefined;
|
|
322
|
+
if (!match) return { status: 'unknown', head: headShort };
|
|
323
|
+
const distGitId = match[1].toLowerCase();
|
|
324
|
+
const distShort = distGitId.slice(0, 8);
|
|
325
|
+
if (!head) return { status: 'unknown', distGitId: distShort };
|
|
326
|
+
// startsWith compares on the stamped id's length (manifest stamps 8; format allows 7-40).
|
|
327
|
+
if (!head.startsWith(distGitId)) {
|
|
328
|
+
return { status: 'stale', reason: 'commit-mismatch', distGitId: distShort, head: headShort };
|
|
329
|
+
}
|
|
330
|
+
const dirty = uncommittedSource(target);
|
|
331
|
+
if (dirty.length) {
|
|
332
|
+
const dirtySinceBuild = latestFileMtime(target, dirty) > latestDistMtime(target);
|
|
333
|
+
if (dirtySinceBuild) {
|
|
334
|
+
return { status: 'stale', reason: 'uncommitted-source', distGitId: distShort, head: headShort, modified: dirty.slice(0, 10) };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return { status: 'fresh', distGitId: distShort, head: headShort };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── signal: live CDP health (lazy — only when a port is given) ────────────────
|
|
341
|
+
async function cdpCheck(target: string, cdpPort?: number): Promise<CdpCheck> {
|
|
342
|
+
if (!cdpPort) return { status: 'skipped' };
|
|
343
|
+
try {
|
|
344
|
+
// Lazy import: extension-runtime.ts loads the recipe harness at module
|
|
345
|
+
// scope, so it must never be pulled into the no-browser decision path.
|
|
346
|
+
const { checkExtensionRuntimeHealth } = await import('./runtime.ts');
|
|
347
|
+
const report = await checkExtensionRuntimeHealth(target, cdpPort);
|
|
348
|
+
return report.status === 'PASS'
|
|
349
|
+
? { status: 'pass' }
|
|
350
|
+
: { status: 'fail', findings: report.findings };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return { status: 'fail', findings: [error instanceof Error ? error.message : String(error)] };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Snapshot current fingerprints as the new baseline (host calls after install/build). */
|
|
357
|
+
export function recordReadinessBaseline(target: string): void {
|
|
358
|
+
recordDepsBaseline(target);
|
|
359
|
+
const cache = webpackFingerprint(target);
|
|
360
|
+
writeBaseline(target, 'webpack-cache-state.json', cache);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
interface DecisionCore {
|
|
364
|
+
decision: ReadinessDecision;
|
|
365
|
+
clean?: boolean;
|
|
366
|
+
reasonCode: string;
|
|
367
|
+
reasons: string[];
|
|
368
|
+
actions: RuntimeDecisionAction[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Compute the cheapest action that makes the extension runtime ready. Pure —
|
|
373
|
+
* no process is started, no file in the repo is mutated (baselines live in the
|
|
374
|
+
* OS temp dir). Exit/branch on `report.decision`; never re-parse webpack logs.
|
|
375
|
+
*/
|
|
376
|
+
export async function decideExtensionReadiness(
|
|
377
|
+
target: string,
|
|
378
|
+
options: RuntimeDecisionOptions = {},
|
|
379
|
+
): Promise<RuntimeDecisionReport> {
|
|
380
|
+
const resolved = path.resolve(target);
|
|
381
|
+
if (options.record) recordReadinessBaseline(resolved);
|
|
382
|
+
|
|
383
|
+
const deps = depsCheck(resolved);
|
|
384
|
+
const webpackCache = webpackCacheCheck(resolved);
|
|
385
|
+
const buildLog = buildLogCheck(resolved, options.watchLog);
|
|
386
|
+
const dist = distCheck(resolved);
|
|
387
|
+
const cdp = await cdpCheck(resolved, options.cdpPort);
|
|
388
|
+
const checks = { deps, webpackCache, buildLog, dist, cdp };
|
|
389
|
+
|
|
390
|
+
const install: RuntimeDecisionAction[] = [{ id: 'yarn-install', argv: ['yarn', 'install', '--immutable'], cwd: resolved }];
|
|
391
|
+
const relaunch: RuntimeDecisionAction[] = [{ id: 'relaunch-browser' }];
|
|
392
|
+
const cacheStale = buildLog.reason === 'stale-cache';
|
|
393
|
+
|
|
394
|
+
// Cheapest-first precedence as a flat table (not an if/else ladder): the first
|
|
395
|
+
// matching rule wins, so exactly one action is ever advised.
|
|
396
|
+
const rules: Array<{ when: boolean } & DecisionCore> = [
|
|
397
|
+
{ when: deps.status === 'missing', decision: 'install', reasonCode: 'deps-missing',
|
|
398
|
+
reasons: ['Dependencies are not installed (no yarn install-state markers).'], actions: install },
|
|
399
|
+
{ when: deps.status === 'stale', decision: 'install', reasonCode: 'deps-stale',
|
|
400
|
+
reasons: ['package.json/yarn.lock changed since the recorded install.'], actions: install },
|
|
401
|
+
{ when: buildLog.status === 'errors', decision: 'build', clean: cacheStale,
|
|
402
|
+
reasonCode: cacheStale ? 'webpack-cache-stale' : 'build-errors',
|
|
403
|
+
reasons: [cacheStale
|
|
404
|
+
? 'Webpack is failing on a poisoned cache (ENOENT on a deduped module).'
|
|
405
|
+
: 'Webpack build has errors; fix the source/build before validating.',
|
|
406
|
+
...(buildLog.excerpt ? [buildLog.excerpt] : [])],
|
|
407
|
+
actions: buildAction(resolved, cacheStale) },
|
|
408
|
+
{ when: webpackCache.status === 'stale', decision: 'build', clean: true, reasonCode: 'webpack-cache-stale',
|
|
409
|
+
reasons: ['Webpack build inputs changed since the cache was recorded; clear cache and rebuild.'],
|
|
410
|
+
actions: buildAction(resolved, true) },
|
|
411
|
+
{ when: dist.status === 'no-build', decision: 'build', reasonCode: 'dist-missing',
|
|
412
|
+
reasons: ['No dist/chrome build present.'], actions: buildAction(resolved, false) },
|
|
413
|
+
{ when: dist.status === 'stale', decision: 'build', reasonCode: 'dist-stale',
|
|
414
|
+
reasons: [dist.reason === 'uncommitted-source'
|
|
415
|
+
? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.`
|
|
416
|
+
: `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
|
|
417
|
+
actions: buildAction(resolved, false) },
|
|
418
|
+
{ when: cdp.status === 'pass', decision: 'ready', reasonCode: 'healthy',
|
|
419
|
+
reasons: ['Build is fresh and the extension runtime is healthy over CDP.'], actions: [] },
|
|
420
|
+
{ when: cdp.status === 'fail', decision: 'relaunch', reasonCode: 'runtime-unhealthy',
|
|
421
|
+
reasons: ['Build is fresh but the live extension is unhealthy; relaunch the browser.',
|
|
422
|
+
...(cdp.findings?.slice(0, 3) ?? [])], actions: relaunch },
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
// Fallback: build looks ready but no --cdp-port was given, so liveness is unverified.
|
|
426
|
+
const fallback: DecisionCore = {
|
|
427
|
+
decision: 'relaunch', reasonCode: 'cdp-unknown',
|
|
428
|
+
reasons: ['Build is fresh; browser liveness unverified (pass --cdp-port to confirm `ready`).'],
|
|
429
|
+
actions: relaunch,
|
|
430
|
+
};
|
|
431
|
+
const core = rules.find((rule) => rule.when) ?? fallback;
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
schemaVersion: 1, adapter: 'extension', target: resolved,
|
|
435
|
+
decision: core.decision, clean: core.clean ?? false, reasonCode: core.reasonCode,
|
|
436
|
+
reasons: core.reasons, checks, actions: core.actions,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function buildAction(target: string, clean: boolean): RuntimeDecisionAction[] {
|
|
441
|
+
const actions: RuntimeDecisionAction[] = [];
|
|
442
|
+
if (clean) actions.push({ id: 'clear-webpack-cache', paths: [WEBPACK_CACHE_DIR] });
|
|
443
|
+
actions.push({ id: 'start-webpack-watch', argv: ['yarn', 'start'], cwd: target });
|
|
444
|
+
return actions;
|
|
445
|
+
}
|