@deeeed/metamask-harness 0.5.0 → 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.
- package/CHANGELOG.md +51 -20
- package/adapters/core/inject.sh +1 -0
- package/adapters/extension/cleanup.mjs +1 -1
- package/adapters/extension/inject.mjs +4 -1
- package/adapters/extension/launch-browser.cjs +18 -4
- package/adapters/extension/live.sh +14 -2
- package/adapters/extension/refresh-build.sh +2 -2
- package/adapters/extension/sidepanel-toggle.sh +33 -12
- package/adapters/extension/start-watch.sh +2 -2
- package/adapters/extension/wallet-fixture-state.cjs +38 -24
- package/adapters/manifest.json +32 -3
- package/adapters/mobile/inject.sh +1 -0
- package/adapters/mobile/start-metro.sh +1 -1
- package/adapters/mobile/stop-metro.sh +20 -0
- package/adapters/mobile/verify.sh +1 -1
- package/adapters/shared/activate-repo-node.sh +1 -1
- package/adapters/shared/cli-ux.sh +44 -25
- package/adapters/shared/ensure-runner-deps.sh +30 -0
- package/adapters/shared/log-tui.mjs +4 -4
- package/adapters/shared/open-debug.mjs +1 -1
- package/adapters/shared/reap-checkout-metros.sh +53 -0
- package/adapters/shared/recipe-harness-root.mjs +23 -0
- package/adapters/shared/resolve-farmslot-ports-core.mjs +205 -0
- package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
- package/adapters/shared/resolve-farmslot-ports.sh +19 -126
- package/bin/mm-harness +45 -4
- package/dist/adapters/extension/runtime.js +3 -1
- package/dist/adapters/mobile/provision.js +34 -1
- package/dist/adapters/mobile/runtime-decision.js +3 -1
- package/dist/adapters/resolve-farmslot-ports.js +22 -0
- package/dist/adapters/slot-ports.js +18 -29
- package/dist/cli.js +32 -1463
- package/dist/commands/call.js +183 -0
- package/dist/commands/completion-candidates.js +58 -0
- package/dist/commands/doctor.js +101 -0
- package/dist/commands/ensure-ready.js +24 -0
- package/dist/commands/flows.js +62 -0
- package/dist/commands/launch/extension.js +40 -0
- package/dist/commands/{launch.js → launch/index.js} +15 -47
- package/dist/commands/launch/mobile.js +10 -0
- package/dist/commands/manifest.js +72 -0
- package/dist/commands/parse-args.js +189 -0
- package/dist/commands/provision.js +136 -0
- package/dist/commands/resolve-extension.js +23 -0
- package/dist/commands/run-engine.js +341 -0
- package/dist/commands/run.js +217 -0
- package/dist/commands/runtime-decision.js +58 -0
- package/dist/commands/runtime-health.js +25 -0
- package/dist/commands/runtime-launch.js +139 -0
- package/dist/commands/self-test.js +52 -0
- package/dist/commands/stop.js +52 -0
- package/dist/harness.js +8 -48
- package/dist/mm-harness-cli.js +13 -8
- package/docs/CLI-SPEC.md +1 -1
- package/docs/CODE-MAP.md +62 -0
- package/library/README.md +14 -0
- package/library/actions/core/perps/_controller.mjs +1 -1
- package/library/actions/extension/platform/cdp.mjs +2 -2
- package/library/actions/extension/wallet/ensure_unlocked.mjs +7 -1
- package/library/actions/harness-exports.mjs +27 -0
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +14 -2
- package/library/actions/mobile/wallet/setup.mjs +1 -1
- package/package.json +6 -7
- package/src/adapters/core/surface.ts +0 -71
- package/src/adapters/extension/ensure-ready.ts +0 -185
- package/src/adapters/extension/extension-id.ts +0 -107
- package/src/adapters/extension/runtime-decision.ts +0 -445
- package/src/adapters/extension/runtime.ts +0 -407
- package/src/adapters/extension/surface.ts +0 -88
- package/src/adapters/mobile/deps-markers.ts +0 -21
- package/src/adapters/mobile/prepare.ts +0 -246
- package/src/adapters/mobile/provision.ts +0 -594
- package/src/adapters/mobile/runtime-decision.ts +0 -459
- package/src/adapters/mobile/surface.ts +0 -71
- package/src/adapters/slot-ports.ts +0 -165
- package/src/adapters/surface.ts +0 -117
- package/src/adapters.ts +0 -601
- package/src/cli-color.ts +0 -92
- package/src/cli-commands.ts +0 -250
- package/src/cli-version.ts +0 -141
- package/src/cli.ts +0 -2091
- package/src/commands/debug.ts +0 -65
- package/src/commands/fixtures.ts +0 -198
- package/src/commands/launch.ts +0 -470
- package/src/commands/logs.ts +0 -99
- package/src/commands/shared.ts +0 -235
- package/src/commands/update.ts +0 -316
- package/src/completions-cache.ts +0 -86
- package/src/doctor.ts +0 -215
- package/src/harness.ts +0 -797
- package/src/heal-bounds.ts +0 -198
- package/src/index.ts +0 -15
- package/src/leaf-invoke.ts +0 -28
- package/src/live-adapter-contract.ts +0 -274
- package/src/manifest.ts +0 -47
- package/src/mm-harness-cli.ts +0 -655
- package/src/paths.ts +0 -198
- package/src/progress.ts +0 -117
- package/src/recording-target.ts +0 -147
- package/src/run-recording.ts +0 -329
- package/src/runner.ts +0 -108
- package/src/types.ts +0 -57
package/src/commands/shared.ts
DELETED
|
@@ -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
|
-
}
|
package/src/commands/update.ts
DELETED
|
@@ -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
|
-
}
|
package/src/completions-cache.ts
DELETED
|
@@ -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
|
-
}
|