@deeeed/metamask-harness 0.5.1 → 0.6.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 (89) hide show
  1. package/CHANGELOG.md +37 -20
  2. package/adapters/core/inject.sh +1 -0
  3. package/adapters/extension/cleanup.mjs +1 -1
  4. package/adapters/extension/inject.mjs +2 -1
  5. package/adapters/extension/launch-browser.cjs +18 -4
  6. package/adapters/extension/live.sh +14 -2
  7. package/adapters/extension/sidepanel-toggle.sh +33 -12
  8. package/adapters/extension/wallet-fixture-state.cjs +38 -24
  9. package/adapters/manifest.json +13 -0
  10. package/adapters/mobile/inject.sh +1 -0
  11. package/adapters/mobile/stop-metro.sh +20 -0
  12. package/adapters/shared/cli-ux.sh +20 -2
  13. package/adapters/shared/ensure-runner-deps.sh +30 -0
  14. package/adapters/shared/reap-checkout-metros.sh +53 -0
  15. package/adapters/shared/recipe-harness-root.mjs +23 -0
  16. package/adapters/shared/resolve-farmslot-ports-core.mjs +15 -6
  17. package/bin/mm-harness +45 -4
  18. package/dist/adapters/extension/runtime.js +3 -1
  19. package/dist/adapters/mobile/provision.js +34 -1
  20. package/dist/adapters/slot-ports.js +3 -8
  21. package/dist/cli.js +32 -1463
  22. package/dist/commands/call.js +183 -0
  23. package/dist/commands/completion-candidates.js +58 -0
  24. package/dist/commands/doctor.js +101 -0
  25. package/dist/commands/ensure-ready.js +24 -0
  26. package/dist/commands/flows.js +62 -0
  27. package/dist/commands/launch/extension.js +40 -0
  28. package/dist/commands/{launch.js → launch/index.js} +15 -47
  29. package/dist/commands/launch/mobile.js +10 -0
  30. package/dist/commands/manifest.js +72 -0
  31. package/dist/commands/parse-args.js +189 -0
  32. package/dist/commands/provision.js +136 -0
  33. package/dist/commands/resolve-extension.js +23 -0
  34. package/dist/commands/run-engine.js +341 -0
  35. package/dist/commands/run.js +217 -0
  36. package/dist/commands/runtime-decision.js +58 -0
  37. package/dist/commands/runtime-health.js +25 -0
  38. package/dist/commands/runtime-launch.js +139 -0
  39. package/dist/commands/self-test.js +52 -0
  40. package/dist/commands/stop.js +52 -0
  41. package/dist/harness.js +8 -48
  42. package/dist/mm-harness-cli.js +13 -8
  43. package/docs/CLI-SPEC.md +1 -1
  44. package/docs/CODE-MAP.md +62 -0
  45. package/library/README.md +14 -0
  46. package/library/actions/extension/platform/cdp.mjs +1 -1
  47. package/library/actions/extension/wallet/ensure_unlocked.mjs +6 -0
  48. package/library/actions/mobile/wallet/ensure_unlocked.mjs +13 -1
  49. package/package.json +6 -7
  50. package/src/adapters/core/surface.ts +0 -71
  51. package/src/adapters/extension/ensure-ready.ts +0 -185
  52. package/src/adapters/extension/extension-id.ts +0 -107
  53. package/src/adapters/extension/runtime-decision.ts +0 -445
  54. package/src/adapters/extension/runtime.ts +0 -407
  55. package/src/adapters/extension/surface.ts +0 -88
  56. package/src/adapters/mobile/deps-markers.ts +0 -21
  57. package/src/adapters/mobile/prepare.ts +0 -246
  58. package/src/adapters/mobile/provision.ts +0 -594
  59. package/src/adapters/mobile/runtime-decision.ts +0 -466
  60. package/src/adapters/mobile/surface.ts +0 -71
  61. package/src/adapters/resolve-farmslot-ports.ts +0 -13
  62. package/src/adapters/slot-ports.ts +0 -158
  63. package/src/adapters/surface.ts +0 -117
  64. package/src/adapters.ts +0 -601
  65. package/src/cli-color.ts +0 -92
  66. package/src/cli-commands.ts +0 -250
  67. package/src/cli-version.ts +0 -141
  68. package/src/cli.ts +0 -2091
  69. package/src/commands/debug.ts +0 -65
  70. package/src/commands/fixtures.ts +0 -198
  71. package/src/commands/launch.ts +0 -470
  72. package/src/commands/logs.ts +0 -99
  73. package/src/commands/shared.ts +0 -235
  74. package/src/commands/update.ts +0 -316
  75. package/src/completions-cache.ts +0 -86
  76. package/src/doctor.ts +0 -215
  77. package/src/harness.ts +0 -797
  78. package/src/heal-bounds.ts +0 -198
  79. package/src/index.ts +0 -15
  80. package/src/leaf-invoke.ts +0 -28
  81. package/src/live-adapter-contract.ts +0 -274
  82. package/src/manifest.ts +0 -47
  83. package/src/mm-harness-cli.ts +0 -655
  84. package/src/paths.ts +0 -198
  85. package/src/progress.ts +0 -117
  86. package/src/recording-target.ts +0 -147
  87. package/src/run-recording.ts +0 -329
  88. package/src/runner.ts +0 -108
  89. package/src/types.ts +0 -57
@@ -1,235 +0,0 @@
1
- // Shared helpers for the mm-harness command modules: flag parsing, adapter
2
- // resolution, script composition, and the teaching-error emitter. Each command
3
- // module composes adapters/ scripts directly via spawnScript.
4
- //
5
- // Composition seam (overridable for contract tests):
6
- // MM_HARNESS_SCRIPT_BIN_<STEM> — override a specific script by its basename.
7
- // For node invocations (bin === process.execPath) the stem is derived from the
8
- // script path in args[0], e.g. MM_HARNESS_SCRIPT_BIN_OPEN_DEBUG_MJS.
9
-
10
- import { spawn, spawnSync } from 'node:child_process';
11
- import path from 'node:path';
12
-
13
- import { detectAdapter } from '../harness.ts';
14
- import { resolveLeafInvoke, shellLeafMissing } from '../leaf-invoke.ts';
15
- import { runnerDir } from '../paths.ts';
16
- import type { MetaMaskRecipeAdapter } from '../types.ts';
17
-
18
- // Exit-code taxonomy (docs/CLI-SPEC.md §5.6).
19
- export const EXIT = { ok: 0, runtime: 1, usage: 2, infra: 3, bounded: 4, validation: 5 } as const;
20
-
21
- // Adapters that launch a live app surface (core is headless).
22
- export type DeviceAdapter = 'mobile' | 'extension';
23
-
24
- export interface ParsedFlags {
25
- positional: string[];
26
- options: Record<string, string | boolean>;
27
- }
28
-
29
- // Positionals + boolean/valued flags; camelCases --foo-bar to fooBar.
30
- export function parseFlags(argv: string[], booleans: Set<string>): ParsedFlags {
31
- const positional: string[] = [];
32
- const options: Record<string, string | boolean> = {};
33
- for (let i = 0; i < argv.length; i += 1) {
34
- const arg = argv[i];
35
- if (!arg.startsWith('--')) {
36
- positional.push(arg);
37
- continue;
38
- }
39
- const body = arg.slice(2);
40
- const eq = body.indexOf('=');
41
- const rawKey = eq === -1 ? body : body.slice(0, eq);
42
- const inline = eq === -1 ? undefined : body.slice(eq + 1);
43
- const key = rawKey.replace(/-([a-z])/gu, (_, c: string) => c.toUpperCase());
44
- if (booleans.has(key)) {
45
- options[key] = inline === undefined ? true : inline !== 'false';
46
- continue;
47
- }
48
- if (inline !== undefined) {
49
- options[key] = inline;
50
- continue;
51
- }
52
- // Valued flag with a following token, unless the next token is itself a flag.
53
- const next = argv[i + 1];
54
- if (next === undefined || next.startsWith('--')) {
55
- options[key] = true;
56
- continue;
57
- }
58
- options[key] = next;
59
- i += 1;
60
- }
61
- return { positional, options };
62
- }
63
-
64
- export function str(options: Record<string, string | boolean>, key: string): string | undefined {
65
- const value = options[key];
66
- return typeof value === 'string' ? value : undefined;
67
- }
68
-
69
- export function flag(options: Record<string, string | boolean>, key: string): boolean {
70
- return options[key] === true;
71
- }
72
-
73
- export function targetOf(options: Record<string, string | boolean>): string {
74
- return path.resolve(str(options, 'target') ?? str(options, 'projectRoot') ?? process.cwd());
75
- }
76
-
77
- const ADAPTER_TOKENS: readonly string[] = ['mobile', 'extension', 'core'];
78
-
79
- // Explicit --adapter/--platform, else an optional hint, else auto-detect from the
80
- // target. Reimplemented here (rather than shared with cli.ts) to avoid a cycle.
81
- export function resolveAdapter(
82
- options: Record<string, string | boolean>,
83
- target: string,
84
- hint?: MetaMaskRecipeAdapter,
85
- ): MetaMaskRecipeAdapter | undefined {
86
- const explicit = str(options, 'adapter') ?? str(options, 'platform');
87
- if (explicit && ADAPTER_TOKENS.includes(explicit)) return explicit as MetaMaskRecipeAdapter;
88
- if (hint) return hint;
89
- return detectAdapter(target);
90
- }
91
-
92
- export interface ScriptResult {
93
- status: number;
94
- output: string;
95
- }
96
-
97
- // Compose an adapters/ script directly. Output is always captured (needed for
98
- // heal classification) and, in human mode, forwarded to stderr. --json keeps
99
- // stdout clean for the machine summary. `env` overlays extra vars onto the
100
- // inherited environment for spawns that need a scoped variable (e.g. color mode).
101
- export function spawnScript(
102
- script: string,
103
- args: string[],
104
- cwd: string,
105
- json: boolean,
106
- env?: Record<string, string>,
107
- ): ScriptResult {
108
- // For node invocations (script === process.execPath) the seam stem is derived
109
- // from args[0] so each spawned script has its own override key.
110
- const isNodeScript = script === process.execPath && args.length > 0;
111
- // Seam stem: for node invocations derive from args[0] so each script has its
112
- // own override key; for shell and other leaves derive from the script basename.
113
- const stem = isNodeScript
114
- ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase()
115
- : path.basename(script).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase();
116
- const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
117
- const bin = override ?? script;
118
- const directArgs = override !== undefined && isNodeScript ? args.slice(1) : args;
119
-
120
- // Shell leaves (.sh) run through bash so file mode need not be executable.
121
- // Bash exits 127 (not a Node spawn error) for a missing file, so pre-check.
122
- if (shellLeafMissing(bin)) {
123
- const message =
124
- `leaf could not start: ${path.basename(bin)} (ENOENT)\n` +
125
- ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
126
- process.stderr.write(`${message}\n`);
127
- return { status: 1, output: message };
128
- }
129
-
130
- const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
131
- const result = spawnSync(invokeBin, spawnArgs, {
132
- cwd,
133
- encoding: 'utf8',
134
- env: env ? { ...process.env, ...env } : process.env,
135
- maxBuffer: 64 * 1024 * 1024,
136
- });
137
- if (result.error) {
138
- // Spawn failure for non-shell leaves (ENOENT/EACCES) or when bash itself
139
- // cannot start. Always emit so a leaf that cannot start is never silent.
140
- // For node invocations the leaf name comes from args[0], not process.execPath.
141
- const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
142
- const code = (result.error as NodeJS.ErrnoException).code ?? 'ESPAWN';
143
- const message =
144
- `leaf could not start: ${leaf} (${code})\n` +
145
- ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
146
- process.stderr.write(`${message}\n`);
147
- return { status: 1, output: message };
148
- }
149
- const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
150
- if (!json && output) process.stderr.write(output);
151
- return { status: result.status ?? 1, output };
152
- }
153
-
154
- // Streaming variant of spawnScript for long-running leaves (mobile prepare:
155
- // native build + Metro + health-bridge poll, minutes long). spawnScript buffers
156
- // via spawnSync and, in --json mode, suppresses output entirely — so those leaves
157
- // run with zero feedback until exit. This tees the child's stdout+stderr to the
158
- // parent's STDERR live (so the --json envelope on stdout stays clean) while still
159
- // capturing the combined output for heal classification. Same seam, leaf-invoke
160
- // resolution, missing-leaf pre-check, and spawn-error contract as spawnScript.
161
- export function spawnScriptStreaming(
162
- script: string,
163
- args: string[],
164
- cwd: string,
165
- env?: Record<string, string>,
166
- ): Promise<ScriptResult> {
167
- const isNodeScript = script === process.execPath && args.length > 0;
168
- const stem = isNodeScript
169
- ? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase()
170
- : path.basename(script).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase();
171
- const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
172
- const bin = override ?? script;
173
- const directArgs = override !== undefined && isNodeScript ? args.slice(1) : args;
174
-
175
- if (shellLeafMissing(bin)) {
176
- const message =
177
- `leaf could not start: ${path.basename(bin)} (ENOENT)\n` +
178
- ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
179
- process.stderr.write(`${message}\n`);
180
- return Promise.resolve({ status: 1, output: message });
181
- }
182
-
183
- const { bin: invokeBin, args: spawnArgs } = resolveLeafInvoke(bin, directArgs);
184
- return new Promise<ScriptResult>((resolve) => {
185
- const child = spawn(invokeBin, spawnArgs, {
186
- cwd,
187
- env: env ? { ...process.env, ...env } : process.env,
188
- stdio: ['ignore', 'pipe', 'pipe'],
189
- });
190
- let output = '';
191
- // Route BOTH child streams to the parent's stderr, live: stdout is reserved
192
- // for the harness --json envelope, so all human/leaf progress goes to stderr.
193
- const tee = (chunk: Buffer): void => {
194
- const text = chunk.toString('utf8');
195
- output += text;
196
- process.stderr.write(text);
197
- };
198
- child.stdout?.on('data', tee);
199
- child.stderr?.on('data', tee);
200
- child.on('error', (error: NodeJS.ErrnoException) => {
201
- const leaf = isNodeScript ? path.basename(args[0]) : path.basename(script);
202
- const code = error.code ?? 'ESPAWN';
203
- const message =
204
- `leaf could not start: ${leaf} (${code})\n` +
205
- ` Next: reinstall mm-harness (npm i -g @deeeed/metamask-harness) — the shell leaf is missing or not executable`;
206
- process.stderr.write(`${message}\n`);
207
- resolve({ status: 1, output: message });
208
- });
209
- child.on('close', (status) => {
210
- resolve({ status: status ?? 1, output });
211
- });
212
- });
213
- }
214
-
215
- // Both escapes from a failed repo-type detection (defined at the detection source
216
- // in harness.ts): where to run it (checkout/target) and how to force it (adapter).
217
- export { ADAPTER_DETECT_NEXT } from '../harness.ts';
218
-
219
- // Teaching-error emitter (exit 2, machine-readable in --json). `userAction` is
220
- // REQUIRED: a teaching error without a reachable escape must not compile, so the
221
- // escape is a typed parameter rather than free-form prose spliced into `message`.
222
- export function usageOut(json: boolean, command: string, message: string, userAction: string): number {
223
- if (json) {
224
- console.log(
225
- JSON.stringify(
226
- { schemaVersion: 1, command, status: 'fail', exitCode: EXIT.usage, error: { code: 'USAGE', message, userAction } },
227
- null,
228
- 2,
229
- ),
230
- );
231
- } else {
232
- console.error(`✗ mm-harness ${command}: ${message}\n Next: ${userAction}`);
233
- }
234
- return EXIT.usage;
235
- }
@@ -1,316 +0,0 @@
1
- // update — keep the globally-installed mm-harness current against the npm registry.
2
- // `mm-harness update` upgrades to the published latest; `--check` reports without
3
- // touching anything; `--json` carries { current, latest, updateAvailable }. A
4
- // passive once-a-day nudge (maybeNudge) prints a single stderr line when a newer
5
- // version exists — it never blocks, never auto-updates, and stays off stdout so
6
- // the --json contract of every other command is untouched. Every failure path
7
- // (no npm, registry unreachable, permission-denied global dir) names its escape.
8
- //
9
- // Two fetch strategies:
10
- // fetchLatest — spawnSync(`npm view`) used by the explicit `update` command;
11
- // respects the user's npm registry config; bounded by timeoutMs.
12
- // fetchLatestAsync — native fetch() used by the passive nudge; never spawns a
13
- // subprocess; hard-aborts at timeoutMs so it cannot block the
14
- // event loop beyond that bound.
15
-
16
- import { spawnSync } from 'node:child_process';
17
- import fs from 'node:fs';
18
- import os from 'node:os';
19
- import path from 'node:path';
20
-
21
- import { runnerDir } from '../paths.ts';
22
- import { EXIT, flag, parseFlags } from './shared.ts';
23
-
24
- export const PACKAGE_NAME = '@deeeed/metamask-harness';
25
- const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1000;
26
- const NUDGE_FETCH_TIMEOUT_MS = 300;
27
-
28
- export function currentVersion(): string {
29
- try {
30
- const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, 'package.json'), 'utf8')) as { version?: string };
31
- return pkg.version ?? '0.0.0';
32
- } catch {
33
- return '0.0.0';
34
- }
35
- }
36
-
37
- function parseVersion(value: string): { nums: number[]; pre: string } {
38
- const cleaned = value.replace(/^v/u, '');
39
- const dash = cleaned.indexOf('-');
40
- const core = dash === -1 ? cleaned : cleaned.slice(0, dash);
41
- const pre = dash === -1 ? '' : cleaned.slice(dash + 1);
42
- const nums = core.split('.').map((part) => Number.parseInt(part, 10) || 0);
43
- while (nums.length < 3) nums.push(0);
44
- return { nums, pre };
45
- }
46
-
47
- // True when `candidate` is a strictly higher version than `base`. A release outranks
48
- // a prerelease of the same core (1.0.0 > 1.0.0-rc.1); prereleases compare lexically.
49
- export function isNewer(candidate: string, base: string): boolean {
50
- const a = parseVersion(candidate);
51
- const b = parseVersion(base);
52
- for (let i = 0; i < 3; i += 1) {
53
- if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
54
- }
55
- if (a.pre === b.pre) return false;
56
- if (a.pre === '') return true;
57
- if (b.pre === '') return false;
58
- return a.pre > b.pre;
59
- }
60
-
61
- export interface FetchResult {
62
- latest?: string;
63
- error?: { kind: 'no-npm' | 'unreachable'; message: string };
64
- }
65
-
66
- // Query the registry dist-tag `latest` via `npm view`. `timeoutMs` bounds the
67
- // wait; callers MUST pass a finite value — an unbounded registry call can hang
68
- // indefinitely. When the timeout fires, spawnSync sets result.signal to SIGTERM.
69
- export function fetchLatest(timeoutMs: number): FetchResult {
70
- const result = spawnSync('npm', ['view', PACKAGE_NAME, 'dist-tags.latest'], {
71
- encoding: 'utf8',
72
- timeout: timeoutMs,
73
- stdio: ['ignore', 'pipe', 'pipe'],
74
- });
75
- if (result.error) {
76
- const code = (result.error as NodeJS.ErrnoException).code;
77
- if (code === 'ENOENT') return { error: { kind: 'no-npm', message: 'npm was not found on PATH' } };
78
- return { error: { kind: 'unreachable', message: result.error.message } };
79
- }
80
- if (result.signal) {
81
- return { error: { kind: 'unreachable', message: `registry fetch timed out after ${timeoutMs}ms` } };
82
- }
83
- if (result.status !== 0) {
84
- return { error: { kind: 'unreachable', message: (result.stderr ?? '').trim() || `npm view exited ${result.status}` } };
85
- }
86
- const latest = (result.stdout ?? '').trim();
87
- if (!latest) return { error: { kind: 'unreachable', message: 'npm returned no dist-tag for latest' } };
88
- return { latest };
89
- }
90
-
91
- // Async variant for the passive nudge: uses the native fetch() API so it never
92
- // spawns a subprocess and never blocks the event loop. Hard-aborts at timeoutMs.
93
- // Skips silently on any error (nudge is best-effort).
94
- async function fetchLatestAsync(timeoutMs: number): Promise<FetchResult> {
95
- const controller = new AbortController();
96
- const timer = setTimeout(() => controller.abort(), timeoutMs);
97
- try {
98
- const url = `https://registry.npmjs.org/-/package/${encodeURIComponent(PACKAGE_NAME)}/dist-tags`;
99
- const res = await fetch(url, { signal: controller.signal });
100
- if (!res.ok) return { error: { kind: 'unreachable', message: `registry returned HTTP ${res.status}` } };
101
- const data = (await res.json()) as Record<string, string>;
102
- const latest = data['latest'];
103
- if (!latest) return { error: { kind: 'unreachable', message: 'npm registry returned no latest dist-tag' } };
104
- return { latest };
105
- } catch (err) {
106
- const msg = (err as Error).name === 'AbortError'
107
- ? `registry fetch timed out after ${timeoutMs}ms`
108
- : (err as Error).message;
109
- return { error: { kind: 'unreachable', message: msg } };
110
- } finally {
111
- clearTimeout(timer);
112
- }
113
- }
114
-
115
- // Non-zero on every failure (EXIT.infra = 3) so it never collides with `--check`'s
116
- // exit 1 = "update available"; each carries the exact reachable escape.
117
- function teachFailure(
118
- json: boolean,
119
- current: string,
120
- code: string,
121
- message: string,
122
- userAction: string,
123
- ): number {
124
- if (json) {
125
- console.log(
126
- JSON.stringify(
127
- { schemaVersion: 1, command: 'update', status: 'fail', current, error: { code, message, userAction } },
128
- null,
129
- 2,
130
- ),
131
- );
132
- } else {
133
- console.error(`✗ mm-harness update: ${message}\n Next: ${userAction}`);
134
- }
135
- return EXIT.infra;
136
- }
137
-
138
- function emitFetchError(json: boolean, error: NonNullable<FetchResult['error']>, current: string): number {
139
- if (error.kind === 'no-npm') {
140
- return teachFailure(
141
- json,
142
- current,
143
- 'NO_NPM',
144
- 'npm was not found on PATH — cannot check for updates',
145
- 'install Node.js (which bundles npm) from https://nodejs.org, then re-run: mm-harness update',
146
- );
147
- }
148
- return teachFailure(
149
- json,
150
- current,
151
- 'REGISTRY_UNREACHABLE',
152
- `could not reach the npm registry (${error.message})`,
153
- 'check your network, then re-run: mm-harness update — or inspect the registry with: npm config get registry',
154
- );
155
- }
156
-
157
- export async function handleUpdate(argv: string[]): Promise<number> {
158
- const { options } = parseFlags(argv, new Set(['check', 'json']));
159
- const json = flag(options, 'json');
160
- const checkOnly = flag(options, 'check');
161
- const current = currentVersion();
162
-
163
- const fetched = fetchLatest(30_000);
164
- if (fetched.error) return emitFetchError(json, fetched.error, current);
165
- const latest = fetched.latest as string;
166
- const updateAvailable = isNewer(latest, current);
167
-
168
- if (checkOnly) {
169
- if (json) {
170
- console.log(JSON.stringify({ schemaVersion: 1, command: 'update', current, latest, updateAvailable }, null, 2));
171
- } else if (updateAvailable) {
172
- console.error(`mm-harness ${current} → ${latest} available · run: mm-harness update`);
173
- } else {
174
- console.log(`mm-harness is up to date (${current}).`);
175
- }
176
- // exit 1 signals "update available"; exit 0 signals up-to-date.
177
- return updateAvailable ? EXIT.runtime : EXIT.ok;
178
- }
179
-
180
- if (!updateAvailable) {
181
- if (json) {
182
- console.log(JSON.stringify({ schemaVersion: 1, command: 'update', current, latest, updateAvailable: false, updated: false }, null, 2));
183
- } else {
184
- console.log(`mm-harness is up to date (${current}).`);
185
- }
186
- return EXIT.ok;
187
- }
188
-
189
- const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
190
- const install = spawnSync('npm', ['i', '-g', `${PACKAGE_NAME}@latest`], {
191
- encoding: 'utf8',
192
- timeout: INSTALL_TIMEOUT_MS,
193
- stdio: ['ignore', 'pipe', 'pipe'],
194
- });
195
- if (install.error) {
196
- const code = (install.error as NodeJS.ErrnoException).code;
197
- if (code === 'ENOENT') return emitFetchError(json, { kind: 'no-npm', message: 'npm was not found on PATH' }, current);
198
- return teachFailure(json, current, 'INSTALL_FAILED', `global install failed (${install.error.message})`, `run it directly to see the error: npm i -g ${PACKAGE_NAME}@latest`);
199
- }
200
- if (install.signal) {
201
- return teachFailure(
202
- json,
203
- current,
204
- 'INSTALL_FAILED',
205
- `global install timed out after ${INSTALL_TIMEOUT_MS / 1000}s — slow network or registry unavailable`,
206
- `check your network, then retry: mm-harness update — or install manually: npm i -g ${PACKAGE_NAME}@latest`,
207
- );
208
- }
209
- const combined = `${install.stdout ?? ''}${install.stderr ?? ''}`;
210
- if (install.status !== 0) {
211
- if (/EACCES|permission denied|EPERM/iu.test(combined)) {
212
- return teachFailure(
213
- json,
214
- current,
215
- 'GLOBAL_DIR_PERMISSION',
216
- 'global install denied — the npm global directory is not writable',
217
- 'either re-run with sudo, or point npm at a user-writable prefix: npm config set prefix ~/.npm-global (then add ~/.npm-global/bin to PATH) and re-run: mm-harness update',
218
- );
219
- }
220
- return teachFailure(
221
- json,
222
- current,
223
- 'INSTALL_FAILED',
224
- `global install failed (${combined.trim() || `npm exited ${install.status}`})`,
225
- `run it directly to see the error: npm i -g ${PACKAGE_NAME}@latest`,
226
- );
227
- }
228
-
229
- // Re-check the registry so the reported new version is the one that resolved,
230
- // not merely the tag we asked for. Best-effort — fall back to the tag if slow.
231
- const rechecked = fetchLatest(30_000);
232
- const installed = rechecked.latest ?? latest;
233
- if (json) {
234
- console.log(JSON.stringify({ schemaVersion: 1, command: 'update', current, latest: installed, updateAvailable: true, updated: true, from: current, to: installed }, null, 2));
235
- } else {
236
- console.log(`mm-harness updated ${current} → ${installed}.`);
237
- }
238
- return EXIT.ok;
239
- }
240
-
241
- // --- Passive update nudge ----------------------------------------------------
242
-
243
- interface NudgeCache {
244
- lastCheck: number;
245
- latest: string;
246
- }
247
-
248
- function cacheFile(): string {
249
- const override = process.env.MM_HARNESS_UPDATE_CACHE;
250
- if (override) return override;
251
- const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
252
- return path.join(base, 'mm-harness', 'update-check.json');
253
- }
254
-
255
- function readCache(file: string): NudgeCache | null {
256
- try {
257
- const data = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<NudgeCache>;
258
- if (typeof data.lastCheck === 'number' && typeof data.latest === 'string') {
259
- return { lastCheck: data.lastCheck, latest: data.latest };
260
- }
261
- } catch {
262
- // Missing or corrupt cache = no prior state; a fresh probe will rebuild it.
263
- }
264
- return null;
265
- }
266
-
267
- function writeCache(file: string, cache: NudgeCache): void {
268
- try {
269
- fs.mkdirSync(path.dirname(file), { recursive: true });
270
- fs.writeFileSync(file, JSON.stringify(cache));
271
- } catch {
272
- // Best-effort; a cache we cannot persist just means the next run re-probes.
273
- }
274
- }
275
-
276
- // Suppressed in CI and when the user opts out — the nudge is for interactive use.
277
- export function nudgeDisabled(): boolean {
278
- return process.env.MM_HARNESS_NO_UPDATE_CHECK === '1' || Boolean(process.env.CI);
279
- }
280
-
281
- // Pure decision, testable without a clock or network.
282
- export function nudgeLine(current: string, latest: string): string | null {
283
- return isNewer(latest, current) ? `mm-harness ${current} → ${latest} available · run: mm-harness update` : null;
284
- }
285
-
286
- // One throttled, bounded, stderr-only line when a newer version is published.
287
- // Called via setImmediate in mm-harness-cli.ts so no nudge code runs before
288
- // parse/dispatch starts. Prints AFTER command output via process.once('exit'),
289
- // giving cleaner UX than mid-output interleaving. The `latest` closure variable
290
- // is captured by the exit handler; an async probe that resolves before
291
- // process.exit() fires updates it automatically. Offline / timeout / no network
292
- // skip silently. Cache timestamp is stamped before the fetch so an abandoned
293
- // probe (process exits within 300ms) does not re-probe on the next invocation.
294
- export async function maybeNudge(now: number = Date.now()): Promise<void> {
295
- if (nudgeDisabled()) return;
296
- const file = cacheFile();
297
- const cache = readCache(file);
298
- const current = currentVersion();
299
- // Mutable: exit handler closes over this so a probe result that arrives before
300
- // process.exit() automatically updates what gets printed.
301
- let latest = cache?.latest ?? '';
302
- // Register once — checks nudgeLine at exit time, picking up the freshest value.
303
- process.once('exit', () => {
304
- const line = nudgeLine(current, latest);
305
- if (line) process.stderr.write(`${line}\n`);
306
- });
307
- if (!cache || now - cache.lastCheck >= NUDGE_INTERVAL_MS) {
308
- // Stamp before the fetch so a killed probe does not re-probe next run.
309
- writeCache(file, { lastCheck: now, latest });
310
- const fetched = await fetchLatestAsync(NUDGE_FETCH_TIMEOUT_MS);
311
- if (fetched.latest) {
312
- latest = fetched.latest;
313
- writeCache(file, { lastCheck: now, latest });
314
- }
315
- }
316
- }
@@ -1,86 +0,0 @@
1
- // Dynamic completions cache (module + file format).
2
- //
3
- // The DISCOVER layer (actions/flows) is the single source that feeds the agent
4
- // (compose), the shell (complete), and docs (cheatsheet). Shell completion
5
- // scripts call the hidden `mm-harness completion-candidates <actions|flows>`
6
- // command; that command reads/writes this per-checkout cache so tab-completion
7
- // never blocks the shell on a manifest/library read.
8
- //
9
- // Format (per-checkout, git-ignored — lives under the runtime dir):
10
- // { version, updatedAt (epoch ms), candidates: { actions?: string[], flows?: string[] } }
11
- // TTL: short; a stale/missing cache falls back to a live read (never an error).
12
- // Invalidated on overlay install (`mm-harness install`) — the overlay is what
13
- // changes the available action/flow vocabulary.
14
-
15
- import fs from 'node:fs';
16
- import path from 'node:path';
17
-
18
- import { recipeRuntimePath } from './paths.ts';
19
-
20
- export const COMPLETION_CACHE_VERSION = 1;
21
- // Short TTL: completions must reflect a freshly-installed overlay quickly; a miss
22
- // is cheap (one manifest read) and never blocks the shell.
23
- export const COMPLETION_CACHE_TTL_MS = 60_000;
24
-
25
- export type CompletionKind = 'actions' | 'flows';
26
-
27
- export interface CompletionCache {
28
- version: number;
29
- updatedAt: number;
30
- candidates: Partial<Record<CompletionKind, string[]>>;
31
- }
32
-
33
- export function completionCachePath(projectRoot: string): string {
34
- return recipeRuntimePath(projectRoot, '.completion-cache.json');
35
- }
36
-
37
- export function readCompletionCache(projectRoot: string): CompletionCache | undefined {
38
- try {
39
- const raw = JSON.parse(fs.readFileSync(completionCachePath(projectRoot), 'utf8')) as CompletionCache;
40
- if (!raw || raw.version !== COMPLETION_CACHE_VERSION || typeof raw.updatedAt !== 'number') return undefined;
41
- return raw;
42
- } catch {
43
- return undefined;
44
- }
45
- }
46
-
47
- export function isCacheFresh(cache: CompletionCache, now: number = Date.now()): boolean {
48
- return now - cache.updatedAt < COMPLETION_CACHE_TTL_MS;
49
- }
50
-
51
- // Return cached candidates for a kind when the cache is present + fresh; else
52
- // undefined (caller does a live read and then writeCompletionCandidates).
53
- export function readFreshCandidates(
54
- projectRoot: string,
55
- kind: CompletionKind,
56
- now: number = Date.now(),
57
- ): string[] | undefined {
58
- const cache = readCompletionCache(projectRoot);
59
- if (!cache || !isCacheFresh(cache, now)) return undefined;
60
- return cache.candidates[kind];
61
- }
62
-
63
- export function writeCompletionCandidates(
64
- projectRoot: string,
65
- kind: CompletionKind,
66
- candidates: string[],
67
- ): void {
68
- const existing = readCompletionCache(projectRoot);
69
- const next: CompletionCache = {
70
- version: COMPLETION_CACHE_VERSION,
71
- updatedAt: Date.now(),
72
- candidates: { ...(existing?.candidates ?? {}), [kind]: candidates },
73
- };
74
- const file = completionCachePath(projectRoot);
75
- fs.mkdirSync(path.dirname(file), { recursive: true });
76
- fs.writeFileSync(file, `${JSON.stringify(next, null, 2)}\n`);
77
- }
78
-
79
- // Invalidated on overlay install (the overlay defines the action/flow vocabulary).
80
- export function invalidateCompletionCache(projectRoot: string): void {
81
- try {
82
- fs.rmSync(completionCachePath(projectRoot));
83
- } catch {
84
- // Absent cache is the desired post-state; nothing to do.
85
- }
86
- }