@deeeed/metamask-harness 0.2.0 → 0.3.1
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 +33 -0
- package/README.md +47 -17
- package/{orchestration → adapters}/core/cleanup.sh +2 -2
- package/{orchestration → adapters}/core/inject.sh +8 -8
- package/{orchestration → adapters}/extension/cleanup.mjs +1 -1
- package/{orchestration → adapters}/extension/console-tail.mjs +3 -0
- package/{orchestration → adapters}/extension/ensure-browser.sh +4 -4
- package/{orchestration → adapters}/extension/inject.mjs +27 -31
- package/{orchestration → adapters}/extension/launch.sh +4 -4
- package/{orchestration → adapters}/extension/live.sh +5 -5
- package/{orchestration → adapters}/extension/readiness.mjs +12 -2
- package/{orchestration → adapters}/extension/refresh-build.sh +1 -1
- package/{orchestration → adapters}/extension/seed-fixture.sh +1 -1
- package/{orchestration → adapters}/extension/start-watch.sh +7 -7
- package/{runner → adapters}/extension/verify.sh +11 -11
- package/{orchestration → adapters}/extension/wallet-fixture-state.cjs +2 -2
- package/{orchestration → adapters}/manifest.json +96 -96
- package/{library/actions → adapters}/mobile/bridge-runtime/cdp-bridge.cjs +5 -5
- package/{orchestration → adapters}/mobile/cleanup.sh +3 -3
- package/{orchestration → adapters}/mobile/inject.sh +22 -24
- package/adapters/mobile/open-device.sh +260 -0
- package/{orchestration/compat-overlays → adapters/mobile/overlay/compat}/README.md +13 -4
- package/adapters/mobile/prewarm-bundle.sh +95 -0
- package/adapters/mobile/start-metro.sh +228 -0
- package/{runner → adapters}/mobile/verify.sh +6 -6
- package/adapters/mobile/wait-for-bridge.sh +108 -0
- package/adapters/mobile/yarn-setup.sh +20 -0
- package/{orchestration/lib → adapters/shared}/cli-ux.sh +5 -9
- package/{orchestration/lib → adapters/shared}/log-tui.mjs +57 -1
- package/{orchestration/lib → adapters/shared}/open-debug.mjs +32 -10
- package/{orchestration/lib → adapters/shared}/resolve-farmslot-ports.sh +47 -0
- package/adapters/shared/sync-wallet-fixture.sh +78 -0
- package/bin/mm-harness +37 -6
- package/docs/CLI-SPEC.md +41 -2
- package/docs/architecture.md +69 -38
- package/docs/runtime-file-conventions.md +3 -3
- 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 +1 -1
- package/library/actions/mobile/platform/bridge.mjs +1 -1
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +1 -1
- package/library/actions/mobile/wallet/setup.mjs +2 -2
- package/library/library.json +1 -1
- package/package.json +8 -8
- package/{orchestration → src/adapters}/extension/runtime-decision.ts +5 -5
- package/{orchestration → src/adapters}/extension/runtime.ts +1 -1
- package/src/adapters/mobile/prepare.ts +210 -0
- package/{orchestration → src/adapters}/mobile/runtime-decision.ts +105 -5
- package/{runner/src → src}/adapters.ts +3 -3
- package/{orchestration/lib/cli-color.mjs → src/cli-color.ts} +21 -13
- package/{orchestration/lib/cli-commands.mjs → src/cli-commands.ts} +52 -45
- package/{orchestration/lib/cli-version.mjs → src/cli-version.ts} +35 -17
- package/{runner/src → src}/cli.ts +48 -40
- package/src/commands/debug.ts +63 -0
- package/src/commands/fixtures.ts +193 -0
- package/{runner/src → src}/commands/launch.ts +200 -54
- package/src/commands/logs.ts +83 -0
- package/{runner/src → src}/commands/shared.ts +40 -30
- package/src/commands/update.ts +316 -0
- package/{runner/src → src}/harness.ts +119 -16
- package/{runner/src → src}/heal-bounds.ts +20 -1
- package/src/index.ts +15 -0
- package/{runner/src → src}/live-adapter-contract.ts +1 -1
- package/{runner/src → src}/mm-harness-cli.ts +64 -3
- package/{runner/src → src}/paths.ts +2 -2
- package/{orchestration/lib/progress.mjs → src/progress.ts} +31 -21
- package/docs/extension-runtime-commands.md +0 -60
- package/docs/harness-cli.md +0 -43
- package/orchestration/compat-overlays/mobile/README.md +0 -13
- package/orchestration/doctor.mjs +0 -72
- package/orchestration/lib/cli-home.mjs +0 -354
- package/orchestration/lib/recipe-paths.mjs +0 -26
- package/orchestration/mobile/launch.sh +0 -137
- package/orchestration/mobile/live.sh +0 -125
- package/orchestration/porcelain/metamask-recipe +0 -99
- package/orchestration/porcelain/mm-recipe +0 -1591
- package/orchestration/porcelain/mme-recipe +0 -1181
- package/runner/src/commands/debug.ts +0 -44
- package/runner/src/commands/fixtures.ts +0 -99
- package/runner/src/commands/logs.ts +0 -60
- package/runner/src/index.ts +0 -6
- /package/{orchestration → adapters}/extension/launch-browser.cjs +0 -0
- /package/{orchestration → adapters}/extension/pin-remote-flags.cjs +0 -0
- /package/{orchestration → adapters}/extension/sidepanel-toggle.sh +0 -0
- /package/{orchestration → adapters}/extension/snapshot-dist.sh +0 -0
- /package/{library/actions → adapters}/mobile/bridge-runtime/lib/cdp-eval.cjs +0 -0
- /package/{library/actions → adapters}/mobile/bridge-runtime/lib/config.cjs +0 -0
- /package/{library/actions → adapters}/mobile/bridge-runtime/lib/issue-capture.cjs +0 -0
- /package/{library/actions → adapters}/mobile/bridge-runtime/lib/target-discovery.cjs +0 -0
- /package/{library/actions → adapters}/mobile/bridge-runtime/lib/ws-client.cjs +0 -0
- /package/{library/actions → adapters}/mobile/bridge-runtime/setup-wallet.sh +0 -0
- /package/{library/actions/mobile/app-overlay → adapters/mobile/overlay}/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -0
- /package/{library/actions/mobile/app-overlay → adapters/mobile/overlay}/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -0
- /package/{orchestration/compat-overlays/mobile → adapters/mobile/overlay/compat}/rn81-message-event-source.patch +0 -0
- /package/{orchestration/lib → adapters/shared}/activate-repo-node.sh +0 -0
- /package/{orchestration/lib → adapters/shared}/ensure-runner-deps.sh +0 -0
- /package/{orchestration/lib → adapters/shared}/harness-path.sh +0 -0
- /package/{orchestration/lib → adapters/shared}/hash-helpers.sh +0 -0
- /package/{orchestration/lib → adapters/shared}/json-field.sh +0 -0
- /package/{orchestration/lib → adapters/shared}/path-defaults.json +0 -0
- /package/{orchestration → src/adapters}/extension/ensure-ready.ts +0 -0
- /package/{orchestration → src/adapters}/extension/extension-id.ts +0 -0
- /package/{orchestration → src/adapters}/mobile/deps-markers.ts +0 -0
- /package/{runner/src → src}/completions-cache.ts +0 -0
- /package/{runner/src → src}/doctor.ts +0 -0
- /package/{runner/src → src}/manifest.ts +0 -0
- /package/{runner/src → src}/recording-target.ts +0 -0
- /package/{runner/src → src}/run-recording.ts +0 -0
- /package/{runner/src → src}/runner.ts +0 -0
- /package/{runner/src → src}/types.ts +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Shared helpers for the mm-harness command modules: flag parsing, adapter
|
|
2
|
-
// resolution,
|
|
3
|
-
// module
|
|
4
|
-
// that live under orchestration/porcelain/; these helpers are the common seam.
|
|
2
|
+
// resolution, script composition, and the teaching-error emitter. Each command
|
|
3
|
+
// module composes adapters/ scripts directly via spawnScript.
|
|
5
4
|
//
|
|
6
|
-
// Composition
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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.
|
|
10
9
|
|
|
11
10
|
import { spawnSync } from 'node:child_process';
|
|
12
11
|
import path from 'node:path';
|
|
@@ -89,50 +88,61 @@ export function resolveAdapter(
|
|
|
89
88
|
return detectAdapter(target);
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
if (adapter === 'mobile') {
|
|
94
|
-
return process.env.MM_HARNESS_MM_RECIPE_BIN ?? path.join(runnerDir, 'orchestration', 'porcelain', 'mm-recipe');
|
|
95
|
-
}
|
|
96
|
-
return process.env.MM_HARNESS_MME_RECIPE_BIN ?? path.join(runnerDir, 'orchestration', 'porcelain', 'mme-recipe');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export interface PorcelainResult {
|
|
91
|
+
export interface ScriptResult {
|
|
100
92
|
status: number;
|
|
101
93
|
output: string;
|
|
102
94
|
}
|
|
103
95
|
|
|
104
|
-
// Compose
|
|
105
|
-
// classification) and, in human mode, forwarded to
|
|
106
|
-
//
|
|
107
|
-
export function
|
|
108
|
-
|
|
96
|
+
// Compose an adapters/ script directly. Output is always captured (needed for
|
|
97
|
+
// heal classification) and, in human mode, forwarded to stderr. --json keeps
|
|
98
|
+
// stdout clean for the machine summary.
|
|
99
|
+
export function spawnScript(
|
|
100
|
+
script: string,
|
|
109
101
|
args: string[],
|
|
110
|
-
|
|
102
|
+
cwd: string,
|
|
111
103
|
json: boolean,
|
|
112
|
-
):
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
104
|
+
): ScriptResult {
|
|
105
|
+
// For node invocations (script === process.execPath) the seam stem is derived
|
|
106
|
+
// from args[0] so each spawned script has its own override key.
|
|
107
|
+
const isNodeScript = script === process.execPath && args.length > 0;
|
|
108
|
+
const stem = isNodeScript
|
|
109
|
+
? path.basename(args[0]).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase()
|
|
110
|
+
: path.basename(script).replace(/[^A-Za-z0-9]/gu, '_').toUpperCase();
|
|
111
|
+
const override = process.env[`MM_HARNESS_SCRIPT_BIN_${stem}`];
|
|
112
|
+
const bin = override ?? script;
|
|
113
|
+
const spawnArgs = override !== undefined && isNodeScript ? args.slice(1) : args;
|
|
114
|
+
const result = spawnSync(bin, spawnArgs, {
|
|
115
|
+
cwd,
|
|
116
116
|
encoding: 'utf8',
|
|
117
117
|
env: process.env,
|
|
118
118
|
maxBuffer: 64 * 1024 * 1024,
|
|
119
119
|
});
|
|
120
120
|
if (result.error) {
|
|
121
|
-
return { status: 1, output: `
|
|
121
|
+
return { status: 1, output: `script could not start (${path.basename(script)}): ${result.error.message}` };
|
|
122
122
|
}
|
|
123
123
|
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
124
124
|
if (!json && output) process.stderr.write(output);
|
|
125
125
|
return { status: result.status ?? 1, output };
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
//
|
|
129
|
-
|
|
128
|
+
// Both escapes from a failed repo-type detection (defined at the detection source
|
|
129
|
+
// in harness.ts): where to run it (checkout/target) and how to force it (adapter).
|
|
130
|
+
export { ADAPTER_DETECT_NEXT } from '../harness.ts';
|
|
131
|
+
|
|
132
|
+
// Teaching-error emitter (exit 2, machine-readable in --json). `userAction` is
|
|
133
|
+
// REQUIRED: a teaching error without a reachable escape must not compile, so the
|
|
134
|
+
// escape is a typed parameter rather than free-form prose spliced into `message`.
|
|
135
|
+
export function usageOut(json: boolean, command: string, message: string, userAction: string): number {
|
|
130
136
|
if (json) {
|
|
131
137
|
console.log(
|
|
132
|
-
JSON.stringify(
|
|
138
|
+
JSON.stringify(
|
|
139
|
+
{ schemaVersion: 1, command, status: 'fail', exitCode: EXIT.usage, error: { code: 'USAGE', message, userAction } },
|
|
140
|
+
null,
|
|
141
|
+
2,
|
|
142
|
+
),
|
|
133
143
|
);
|
|
134
144
|
} else {
|
|
135
|
-
console.error(`✗ mm-harness ${command}: ${message}`);
|
|
145
|
+
console.error(`✗ mm-harness ${command}: ${message}\n Next: ${userAction}`);
|
|
136
146
|
}
|
|
137
147
|
return EXIT.usage;
|
|
138
148
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
}
|