amicus 3.2.0 → 3.2.2
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +45 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/cli-handlers-doctor.js +13 -0
- package/src/opencode-client.js +18 -9
- package/src/utils/doctor-engine-check.js +104 -0
- package/src/utils/engine-ensure.js +72 -0
- package/src/utils/engine-install-scan.js +142 -0
- package/src/utils/engine-lock.js +94 -0
- package/src/utils/engine-repair.js +125 -0
- package/src/utils/mcp-discovery.js +27 -0
- package/src/utils/path-setup.js +78 -25
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Christian Wagner"
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [3.2.2] - 2026-07-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A missing `opencode` engine now self-heals instead of failing every call.**
|
|
13
|
+
When the engine binary is absent at a fanout/start — a skipped
|
|
14
|
+
optional-dependency install or an antivirus quarantine of the npx-cache copy
|
|
15
|
+
the MCP launches — amicus recovers in place by copying the `opencode-*`
|
|
16
|
+
packages from a healthy sibling install (running, global, or another npx
|
|
17
|
+
copy), then proceeds, instead of throwing `engineMissing` on every leg
|
|
18
|
+
(report #2).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`amicus doctor --fix` repairs broken npx-cache engine copies**, self-healing
|
|
23
|
+
the copies the MCP actually launches, not just the running install.
|
|
24
|
+
|
|
25
|
+
## [3.2.1] - 2026-07-17
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **`opencode` engine now resolves under the npx-launched MCP.** The MCP server is
|
|
30
|
+
registered as `npx -y amicus@latest mcp`, which npm installs with the
|
|
31
|
+
`opencode-windows-*` engine packages **hoisted** beside `amicus/` rather than
|
|
32
|
+
nested under it. The resolver only probed the nested location, so a present,
|
|
33
|
+
runnable engine read as missing and every `amicus_fanout` / `amicus_start` leg
|
|
34
|
+
failed instantly with `engineMissing`. Both the PATH builder and the shared
|
|
35
|
+
binary resolver now probe the hoisted root too (#69).
|
|
36
|
+
- **The AVX2 (default) `opencode` build is searched before the `-baseline`
|
|
37
|
+
fallback.** `ensureNodeModulesBinInPath()` prepended PATH one entry at a time, so
|
|
38
|
+
the search order was the reverse of the source order and AVX2-capable machines
|
|
39
|
+
silently ran the slower pre-AVX2 baseline build. PATH is now built as one ordered
|
|
40
|
+
group (default → baseline → `.bin`, across every candidate root).
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **`amicus doctor` cross-install engine check.** A new "OpenCode engine (MCP launch
|
|
45
|
+
path)" check enumerates every install that could serve the MCP — running, global,
|
|
46
|
+
and each npx-cache copy — and verifies the engine in each, so a green doctor can
|
|
47
|
+
no longer hide a broken copy the MCP actually launches. A broken single npx copy
|
|
48
|
+
(the unambiguous failure) is an error; ambiguous cases warn and name the exact
|
|
49
|
+
path.
|
|
50
|
+
- The runtime `engineMissing` error now prints the roots it searched, making an
|
|
51
|
+
npx-cache-vs-global install divergence visible at the point of failure.
|
|
52
|
+
|
|
8
53
|
## [3.2.0] - 2026-07-16
|
|
9
54
|
|
|
10
55
|
### Added
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"mcpName": "io.github.BourbonDog/amicus",
|
|
5
5
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
6
6
|
"keywords": [
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"scripts": {
|
|
49
49
|
"start": "node --experimental-top-level-await --experimental-vm-modules bin/amicus.js",
|
|
50
50
|
"test": "jest",
|
|
51
|
-
"test:integration": "jest --testPathIgnorePatterns='
|
|
52
|
-
"test:all": "jest --testPathIgnorePatterns='/node_modules/' --testPathIgnorePatterns='
|
|
51
|
+
"test:integration": "jest --testPathIgnorePatterns='worktrees' --testMatch='**/tests/**/*.integration.test.js'",
|
|
52
|
+
"test:all": "jest --testPathIgnorePatterns='/node_modules/' --testPathIgnorePatterns='worktrees' && node scripts/mark-test-passed.js",
|
|
53
53
|
"test:e2e:mcp": "jest tests/mcp-repomix-e2e.integration.test.js --testTimeout=180000 --forceExit",
|
|
54
54
|
"posttest": "node scripts/mark-test-passed.js",
|
|
55
55
|
"lint": "eslint src/",
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
const HINTS = require('./utils/remediation-hints');
|
|
5
5
|
// B14/4.3: 'mcp' + 'mcp-legacy' check bodies (mirrors the B15 tmpSweep split — see file header).
|
|
6
6
|
const mcpChecks = require('./utils/doctor-mcp-checks');
|
|
7
|
+
// engine-mcp check body — verifies the engine in the npx-cache copies the MCP
|
|
8
|
+
// actually launches (bug report #1). Split out to keep this file under the gate.
|
|
9
|
+
const engineCheck = require('./utils/doctor-engine-check');
|
|
7
10
|
|
|
8
11
|
const MAX_CATALOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h (mirrors model-catalog DEFAULT_MAX_AGE_MS)
|
|
9
12
|
|
|
@@ -36,6 +39,11 @@ function realDeps() {
|
|
|
36
39
|
ensureNodeModulesBinInPath();
|
|
37
40
|
return hasOpencodeBinary();
|
|
38
41
|
},
|
|
42
|
+
// engine-mcp check: probe the engine in every install that could serve the
|
|
43
|
+
// MCP (running/global/npx-cache), not just the one doctor runs from (#1).
|
|
44
|
+
scanEngineInstalls: () => require('./utils/engine-install-scan').scanEngineInstalls(),
|
|
45
|
+
// report #2: copy-from-sibling self-heal for `doctor --fix`.
|
|
46
|
+
repairEngine: (o) => require('./utils/engine-repair').repairEngine(o),
|
|
39
47
|
getElectronPath: () => require('./sidecar/interactive-process').getElectronPath(),
|
|
40
48
|
// #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
|
|
41
49
|
// stays separate; repair only runs when fix is requested.
|
|
@@ -141,6 +149,11 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
141
149
|
: { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngineAv }
|
|
142
150
|
)));
|
|
143
151
|
|
|
152
|
+
// Cross-install: verify the engine in the npx-cache copies the MCP actually
|
|
153
|
+
// launches (`npx -y amicus@latest mcp`), so a green 'opencode-bin' (the running
|
|
154
|
+
// install) can't hide a broken copy the MCP would spawn (bug report #1/#4).
|
|
155
|
+
checks.push(await guardAsync('engine-mcp', 'OpenCode engine (MCP launch path)', () => engineCheck.evaluateEngineMcp(d)));
|
|
156
|
+
|
|
144
157
|
checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
|
|
145
158
|
if (d.getElectronPath()) {
|
|
146
159
|
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed', hint: null };
|
package/src/opencode-client.js
CHANGED
|
@@ -625,18 +625,27 @@ function buildServerHandle(sdkServer, deps = {}) {
|
|
|
625
625
|
* @returns {Promise<{client: object, server: {url: string, close: Function}}>}
|
|
626
626
|
*/
|
|
627
627
|
async function startServer(options = {}) {
|
|
628
|
-
// Fail fast
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
// auto-repair — re-running the opencode postinstall is the same flaky trap.
|
|
628
|
+
// Fail fast when the opencode engine binary is absent (skipped
|
|
629
|
+
// optionalDependency install or AV quarantine). Before giving up, attempt an
|
|
630
|
+
// in-place self-heal: COPY the opencode-* packages from a healthy sibling
|
|
631
|
+
// install (engine-ensure) — NOT re-running the opencode postinstall, which is
|
|
632
|
+
// the same flaky optional-dependency trap. Only runs when truly missing; the
|
|
633
|
+
// fast path skips it. `_hasOpencodeBinary` / `_ensureEngine` are test seams.
|
|
635
634
|
const hasOpencodeBinary = options._hasOpencodeBinary
|
|
636
635
|
|| require('./utils/path-setup').hasOpencodeBinary;
|
|
637
636
|
if (!hasOpencodeBinary()) {
|
|
638
|
-
const
|
|
639
|
-
|
|
637
|
+
const ensureEngine = options._ensureEngine
|
|
638
|
+
|| require('./utils/engine-ensure').ensureEngine;
|
|
639
|
+
const healed = await ensureEngine().catch(() => ({ ok: false }));
|
|
640
|
+
if (!healed.ok) {
|
|
641
|
+
const HINTS = require('./utils/remediation-hints');
|
|
642
|
+
// Append the roots we probed so an npx-cache-vs-global divergence is visible
|
|
643
|
+
// at the point of failure, not just in `amicus doctor` (report #4).
|
|
644
|
+
const opencodeRoots = options._opencodeRoots
|
|
645
|
+
|| require('./utils/path-setup').opencodeRoots;
|
|
646
|
+
const note = healed.reason ? ` (self-heal: ${healed.reason})` : '';
|
|
647
|
+
throw new Error(`${HINTS.engineMissing} Searched: ${opencodeRoots().join(', ')}${note}`);
|
|
648
|
+
}
|
|
640
649
|
}
|
|
641
650
|
|
|
642
651
|
const createOpencodeServer = await getCreateOpencodeServer();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/doctor-engine-check
|
|
3
|
+
* The `engine-mcp` doctor check ("OpenCode engine (MCP launch path)"), split out
|
|
4
|
+
* of src/cli-handlers-doctor.js to keep that file under the 300-line gate
|
|
5
|
+
* (mirrors doctor-mcp-checks.js).
|
|
6
|
+
*
|
|
7
|
+
* The existing `opencode-bin` check verifies the engine in the RUNNING install.
|
|
8
|
+
* This one verifies the copies the MCP actually launches from — the npx-cache
|
|
9
|
+
* installs `npx -y amicus@latest mcp` resolves to — so a green doctor can no
|
|
10
|
+
* longer hide a broken npx copy (bug report #1). Reporting only; no self-heal.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const HINTS = require('./remediation-hints');
|
|
16
|
+
|
|
17
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{scanEngineInstalls: () => {installs:Array, mcpLaunch:string}}} d
|
|
21
|
+
* @returns {{id,name,status,message,hint}}
|
|
22
|
+
*/
|
|
23
|
+
function evaluateEngineInstalls(d) {
|
|
24
|
+
const id = 'engine-mcp';
|
|
25
|
+
const name = 'OpenCode engine (MCP launch path)';
|
|
26
|
+
const { installs, mcpLaunch } = d.scanEngineInstalls();
|
|
27
|
+
|
|
28
|
+
if (mcpLaunch === 'none') {
|
|
29
|
+
return { id, name, status: 'ok', message: 'no amicus MCP registered — not checked', hint: null };
|
|
30
|
+
}
|
|
31
|
+
if (mcpLaunch === 'path') {
|
|
32
|
+
return {
|
|
33
|
+
id, name, status: 'ok',
|
|
34
|
+
message: 'MCP launches from a fixed path — covered by the OpenCode binary check', hint: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 'npx' (and the 'unknown' fallback): verify the npx-cache copies, the ones
|
|
39
|
+
// subject to optional-dependency skips and AV quarantine on every re-resolve.
|
|
40
|
+
const npxCopies = installs.filter((i) => i.kind === 'npx');
|
|
41
|
+
if (npxCopies.length === 0) {
|
|
42
|
+
return {
|
|
43
|
+
id, name, status: 'warn',
|
|
44
|
+
message: 'MCP launches via npx; no cached copy to inspect yet — run one fanout, then re-run doctor',
|
|
45
|
+
hint: null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const broken = npxCopies.filter((i) => !i.engineOk);
|
|
50
|
+
if (broken.length === 0) {
|
|
51
|
+
return {
|
|
52
|
+
id, name, status: 'ok',
|
|
53
|
+
message: `engine present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,
|
|
54
|
+
hint: null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const detail = broken
|
|
59
|
+
.map((i) => `${i.pkgDir} (searched: ${(i.roots || []).join(', ')})`)
|
|
60
|
+
.join('; ');
|
|
61
|
+
// Exactly one npx copy and it is broken → unambiguous: that IS the copy the
|
|
62
|
+
// MCP will launch, and every call will fail. Elsewhere the hash npx selects is
|
|
63
|
+
// ambiguous, so warn (still naming the exact broken path) rather than error.
|
|
64
|
+
const status = npxCopies.length === 1 ? 'error' : 'warn';
|
|
65
|
+
const lead = status === 'error'
|
|
66
|
+
? 'engine missing from the npx-cache copy the MCP launches'
|
|
67
|
+
: `engine missing from ${broken.length}/${npxCopies.length} npx-cache copies`;
|
|
68
|
+
return { id, name, status, message: `${lead}: ${detail}`, hint: HINTS.reinstallEngineAv };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Fix-aware wrapper. When d.fix and the scan shows broken npx-cache copies, copy
|
|
73
|
+
* the engine into each via d.repairEngine, then re-report from a fresh scan.
|
|
74
|
+
* Without d.fix — or when nothing is copy-fixable — returns the plain verdict.
|
|
75
|
+
* @param {object} d doctor deps (scanEngineInstalls, fix?, repairEngine?)
|
|
76
|
+
* @returns {Promise<{id,name,status,message,hint}>}
|
|
77
|
+
*/
|
|
78
|
+
async function evaluateEngineMcp(d) {
|
|
79
|
+
const verdict = evaluateEngineInstalls(d);
|
|
80
|
+
if (!d.fix || verdict.status === 'ok') { return verdict; }
|
|
81
|
+
|
|
82
|
+
const { installs } = d.scanEngineInstalls();
|
|
83
|
+
const broken = installs.filter((i) => i.kind === 'npx' && !i.engineOk);
|
|
84
|
+
if (broken.length === 0) { return verdict; }
|
|
85
|
+
|
|
86
|
+
const results = [];
|
|
87
|
+
for (const b of broken) {
|
|
88
|
+
let r;
|
|
89
|
+
try { r = await d.repairEngine({ destPkgDir: b.pkgDir }); }
|
|
90
|
+
catch (e) { r = { repaired: false, reason: e && e.message }; }
|
|
91
|
+
results.push({ pkgDir: b.pkgDir, ...r });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const after = evaluateEngineInstalls(d); // fresh scan reflects the copies
|
|
95
|
+
if (after.status === 'ok') {
|
|
96
|
+
const n = results.length;
|
|
97
|
+
return { ...after, message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})` };
|
|
98
|
+
}
|
|
99
|
+
const failed = results.filter((r) => !r.repaired)
|
|
100
|
+
.map((r) => `${r.pkgDir}${r.reason ? ` — ${r.reason}` : ''}`).join('; ');
|
|
101
|
+
return { ...after, message: `${after.message}; self-heal incomplete: ${failed}` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { evaluateEngineInstalls, evaluateEngineMcp };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-ensure
|
|
3
|
+
* ensureEngine() — runtime engine self-heal at server start (report #2).
|
|
4
|
+
*
|
|
5
|
+
* The single-flight wrapper opencode-client.startServer calls when the engine
|
|
6
|
+
* binary is missing: fast-path hasOpencodeBinary(); otherwise copy the engine
|
|
7
|
+
* from a healthy sibling install via engine-repair, refresh PATH, and re-check.
|
|
8
|
+
* A module-level promise stops concurrent fanout-leg server starts in one
|
|
9
|
+
* process from re-copying. Only success is memoized; a failed attempt clears the
|
|
10
|
+
* guard so a later call may retry. Mirrors src/sidecar/electron-ensure.js.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const { hasOpencodeBinary: defaultHas } = require('./path-setup');
|
|
16
|
+
|
|
17
|
+
let _ensurePromise = null;
|
|
18
|
+
|
|
19
|
+
/** Test-only: clear the single-flight guard so each test starts clean. */
|
|
20
|
+
function _resetEnsureEngine() {
|
|
21
|
+
_ensurePromise = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure the opencode engine is present, self-healing by copy if missing.
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {object} [opts.deps] injected { hasOpencodeBinary, repairEngine, ensurePath, logProgress }
|
|
28
|
+
* @param {object} [opts.repairOptions] forwarded to repairEngine (destPkgDir, etc.)
|
|
29
|
+
* @returns {Promise<{ok:boolean, reason?:string, donor?:string}>}
|
|
30
|
+
*/
|
|
31
|
+
function ensureEngine({ deps = {}, repairOptions = {} } = {}) {
|
|
32
|
+
const has = deps.hasOpencodeBinary || defaultHas;
|
|
33
|
+
const repair = deps.repairEngine || ((o) => require('./engine-repair').repairEngine(o));
|
|
34
|
+
const ensurePath = deps.ensurePath || require('./path-setup').ensureNodeModulesBinInPath;
|
|
35
|
+
const logProgress = deps.logProgress
|
|
36
|
+
|| ((msg) => { try { process.stderr.write(`${msg}\n`); } catch { /* ignore */ } });
|
|
37
|
+
|
|
38
|
+
// Fast path: already present. Cheap disk stat — safe every call.
|
|
39
|
+
if (has()) { return Promise.resolve({ ok: true }); }
|
|
40
|
+
|
|
41
|
+
// Single-flight: reuse an in-flight repair.
|
|
42
|
+
if (_ensurePromise) { return _ensurePromise; }
|
|
43
|
+
|
|
44
|
+
_ensurePromise = (async () => {
|
|
45
|
+
logProgress('[amicus] OpenCode engine missing — self-healing by copying from a healthy install...');
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = await repair({ ...repairOptions });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return { ok: false, reason: `engine self-heal failed: ${err && err.message}` };
|
|
51
|
+
}
|
|
52
|
+
if (has()) {
|
|
53
|
+
logProgress('[amicus] OpenCode engine restored.');
|
|
54
|
+
return { ok: true, donor: result && result.donor };
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, reason: (result && result.reason) || 'engine self-heal did not restore the binary' };
|
|
57
|
+
})().then((r) => {
|
|
58
|
+
if (r.ok) {
|
|
59
|
+
try { ensurePath(); } catch { /* ignore */ } // refresh PATH so spawn('opencode') resolves
|
|
60
|
+
} else {
|
|
61
|
+
_ensurePromise = null; // failure is not memoized — a later call may retry
|
|
62
|
+
}
|
|
63
|
+
return r;
|
|
64
|
+
}, (err) => {
|
|
65
|
+
_ensurePromise = null;
|
|
66
|
+
throw err;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return _ensurePromise;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { ensureEngine, _resetEnsureEngine };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-install-scan
|
|
3
|
+
* Discover + probe every amicus install that could serve the MCP (running,
|
|
4
|
+
* global, npx-cache), so `amicus doctor` verifies the copy the MCP actually
|
|
5
|
+
* launches — not just the copy doctor happens to run from.
|
|
6
|
+
*
|
|
7
|
+
* The MCP is registered as `npx -y amicus@latest mcp` (scripts/postinstall.js),
|
|
8
|
+
* so it runs from an npx-cache copy, while `amicus doctor` typically inspects the
|
|
9
|
+
* global install on PATH. When those diverge, doctor can report the engine
|
|
10
|
+
* "found" (global) while the npx copy the MCP launches is broken and every call
|
|
11
|
+
* fails — the bug report's green-while-broken defect (#1). This enumerates
|
|
12
|
+
* running + global + each npx-cache copy and probes the opencode engine in each
|
|
13
|
+
* via the #69 dual-root resolver.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
/** Default npm cache dir: %LocalAppData%/npm-cache on win32, else ~/.npm. */
|
|
22
|
+
function defaultNpmCacheDir(platform) {
|
|
23
|
+
if (platform === 'win32') {
|
|
24
|
+
const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
25
|
+
return path.join(local, 'npm-cache');
|
|
26
|
+
}
|
|
27
|
+
return path.join(os.homedir(), '.npm');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Best-effort `npm root -g`. Never throws; returns null on any failure. */
|
|
31
|
+
function defaultNpmRootG() {
|
|
32
|
+
try {
|
|
33
|
+
const { execFileSync } = require('child_process');
|
|
34
|
+
const out = execFileSync('npm', ['root', '-g'], {
|
|
35
|
+
encoding: 'utf-8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
36
|
+
});
|
|
37
|
+
return String(out).trim() || null;
|
|
38
|
+
} catch (_e) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Run fn, swallowing any throw and returning fallback. */
|
|
44
|
+
function safe(fn, fallback) {
|
|
45
|
+
try { return fn(); } catch (_e) { return fallback; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Drop installs whose pkgDir resolves to the same real path; keep the first. */
|
|
49
|
+
function dedupByRealpath(installs, fs) {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const inst of installs) {
|
|
53
|
+
const real = safe(() => fs.realpathSync(inst.pkgDir), inst.pkgDir);
|
|
54
|
+
const key = path.normalize(real);
|
|
55
|
+
if (seen.has(key)) { continue; }
|
|
56
|
+
seen.add(key);
|
|
57
|
+
out.push(inst);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The amicus installs that could serve the MCP, highest-priority first
|
|
64
|
+
* (running, global, then npx-cache copies). All I/O behind seams.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} [deps]
|
|
67
|
+
* @param {object} [deps.fs] - fs module (existsSync/readdirSync/realpathSync)
|
|
68
|
+
* @param {string} [deps.platform] - process.platform override
|
|
69
|
+
* @param {string} [deps.runningPkgDir] - this process's amicus package root
|
|
70
|
+
* @param {string} [deps.npmCacheDir] - npm cache dir holding _npx/
|
|
71
|
+
* @param {() => (string|null)} [deps.npmRootG] - resolver for `npm root -g`
|
|
72
|
+
* @returns {Array<{kind:string, pkgDir:string}>}
|
|
73
|
+
*/
|
|
74
|
+
function listAmicusInstalls(deps = {}) {
|
|
75
|
+
const fs = deps.fs || require('fs');
|
|
76
|
+
const platform = deps.platform || process.platform;
|
|
77
|
+
const runningPkgDir = deps.runningPkgDir || path.join(__dirname, '..', '..');
|
|
78
|
+
const npmCacheDir = deps.npmCacheDir || defaultNpmCacheDir(platform);
|
|
79
|
+
const npmRootG = deps.npmRootG || defaultNpmRootG;
|
|
80
|
+
|
|
81
|
+
const raw = [{ kind: 'running', pkgDir: runningPkgDir }];
|
|
82
|
+
|
|
83
|
+
// Global — best-effort; `npm root -g` → <root>/amicus. Never fails the scan.
|
|
84
|
+
const gRoot = safe(() => npmRootG(), null);
|
|
85
|
+
if (gRoot) {
|
|
86
|
+
const gDir = path.join(gRoot, 'amicus');
|
|
87
|
+
if (safe(() => fs.existsSync(gDir), false)) {
|
|
88
|
+
raw.push({ kind: 'global', pkgDir: gDir });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// npx caches — <cache>/_npx/<hash>/node_modules/amicus for each hash present.
|
|
93
|
+
const npxRoot = path.join(npmCacheDir, '_npx');
|
|
94
|
+
for (const hash of safe(() => fs.readdirSync(npxRoot), [])) {
|
|
95
|
+
const pkgDir = path.join(npxRoot, hash, 'node_modules', 'amicus');
|
|
96
|
+
if (safe(() => fs.existsSync(pkgDir), false)) {
|
|
97
|
+
raw.push({ kind: 'npx', pkgDir });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return dedupByRealpath(raw, fs);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Classify the MCP launch method from the amicus registration config.
|
|
106
|
+
* @param {{command?:string, args?:unknown[]}|null|undefined} config
|
|
107
|
+
* @returns {'npx'|'path'|'none'|'unknown'}
|
|
108
|
+
*/
|
|
109
|
+
function classifyLaunch(config) {
|
|
110
|
+
const { isAmicusMcpConfig, normalizeToken } = require('./mcp-self-identity');
|
|
111
|
+
if (!config || typeof config !== 'object') { return 'none'; }
|
|
112
|
+
if (config.command && normalizeToken(config.command) === 'npx') { return 'npx'; }
|
|
113
|
+
if (isAmicusMcpConfig(config)) { return 'path'; }
|
|
114
|
+
return 'unknown';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Enumerate serving installs, probe the engine in each, and classify how the
|
|
119
|
+
* MCP launches.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} [deps] - listAmicusInstalls seams, plus:
|
|
122
|
+
* @param {(d:{pkgDir:string}) => boolean} [deps.hasOpencodeBinary]
|
|
123
|
+
* @param {(d:{pkgDir:string}) => string[]} [deps.opencodeRoots]
|
|
124
|
+
* @param {() => (object|null)} [deps.readAmicusMcpConfig]
|
|
125
|
+
* @returns {{installs: Array<{kind,pkgDir,engineOk,roots}>, mcpLaunch: string}}
|
|
126
|
+
*/
|
|
127
|
+
function scanEngineInstalls(deps = {}) {
|
|
128
|
+
const hasOpencodeBinary = deps.hasOpencodeBinary || require('./path-setup').hasOpencodeBinary;
|
|
129
|
+
const opencodeRoots = deps.opencodeRoots || require('./path-setup').opencodeRoots;
|
|
130
|
+
const readAmicusMcpConfig = deps.readAmicusMcpConfig
|
|
131
|
+
|| (() => require('./mcp-discovery').readAmicusMcpConfig());
|
|
132
|
+
|
|
133
|
+
const installs = listAmicusInstalls(deps).map((i) => ({
|
|
134
|
+
...i,
|
|
135
|
+
engineOk: !!hasOpencodeBinary({ pkgDir: i.pkgDir }),
|
|
136
|
+
roots: opencodeRoots({ pkgDir: i.pkgDir }),
|
|
137
|
+
}));
|
|
138
|
+
const mcpLaunch = classifyLaunch(safe(() => readAmicusMcpConfig(), null));
|
|
139
|
+
return { installs, mcpLaunch };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { listAmicusInstalls, scanEngineInstalls, classifyLaunch };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-lock
|
|
3
|
+
* Stale-aware single-flight lock for the engine self-heal (report #2).
|
|
4
|
+
*
|
|
5
|
+
* Only one process may copy the opencode engine into a given install at a time:
|
|
6
|
+
* the report saw multiple live MCP processes, so two could self-heal the SAME
|
|
7
|
+
* npx-cache copy at once and a leg could spawn a half-written opencode.exe. The
|
|
8
|
+
* lock is a file in the OS temp dir keyed by the destination pkgDir, recording
|
|
9
|
+
* the holder PID + timestamp so a LATER caller can detect and STEAL a lock
|
|
10
|
+
* orphaned by a killed/crashed repair rather than wedging every future repair.
|
|
11
|
+
* Mirrors src/sidecar/electron-lock.js (kept separate so the shipped GUI heal is
|
|
12
|
+
* never touched).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fsDefault = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
/** A real engine copy (a few tens of MB) finishes well within this. */
|
|
22
|
+
const STALE_MS = 15 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** Temp-dir lockfile path, keyed by the destination install dir. */
|
|
25
|
+
function lockPathFor(pkgDir) {
|
|
26
|
+
const key = require('crypto').createHash('sha1').update(pkgDir).digest('hex').slice(0, 16);
|
|
27
|
+
return path.join(os.tmpdir(), `amicus-engine-repair-${key}.lock`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Is an existing lockfile orphaned? Stale when it predates STALE_MS, its holder
|
|
32
|
+
* process is gone (ESRCH), or it is empty / corrupt. A lock held by a live,
|
|
33
|
+
* recent process is NOT stale (real contention).
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
36
|
+
function isStaleLock(lockPath, fs = fsDefault, now = Date.now()) {
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = fs.readFileSync(lockPath, 'utf-8');
|
|
40
|
+
} catch {
|
|
41
|
+
return false; // already gone — absent, not stale
|
|
42
|
+
}
|
|
43
|
+
let meta;
|
|
44
|
+
try {
|
|
45
|
+
meta = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return true; // empty / corrupt -> orphaned
|
|
48
|
+
}
|
|
49
|
+
if (typeof meta.at === 'number' && now - meta.at > STALE_MS) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (typeof meta.pid === 'number') {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(meta.pid, 0); // throws if the process is gone
|
|
55
|
+
return false; // holder alive
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return !!(err && err.code === 'ESRCH'); // ESRCH => dead; EPERM => alive
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Acquire the single-flight repair lock. Throws an EEXIST-coded error ONLY when
|
|
65
|
+
* a live, recent process genuinely holds it; otherwise steals an orphaned lock.
|
|
66
|
+
* @param {{pkgDir:string, fs?:object}} opts
|
|
67
|
+
* @returns {{ release: () => void }}
|
|
68
|
+
*/
|
|
69
|
+
function acquireRepairLock({ pkgDir, fs = fsDefault }) {
|
|
70
|
+
const lockPath = lockPathFor(pkgDir);
|
|
71
|
+
|
|
72
|
+
function create() {
|
|
73
|
+
// Atomic exclusive create WITH content: never observable empty. 'wx' throws
|
|
74
|
+
// EEXIST if held.
|
|
75
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
create();
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (!e || e.code !== 'EEXIST') { throw e; }
|
|
82
|
+
if (!isStaleLock(lockPath, fs)) { throw e; } // live holder -> honest contention
|
|
83
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
84
|
+
create();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
release() {
|
|
89
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { acquireRepairLock, isStaleLock, lockPathFor, STALE_MS };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/engine-repair
|
|
3
|
+
* Engine self-heal primitive (report #2): make the opencode engine present ON
|
|
4
|
+
* DISK in a target amicus install by COPYING the opencode-* packages from a
|
|
5
|
+
* healthy sibling install (running/global/npx). No network, and the donor is on
|
|
6
|
+
* the same machine so its platform binaries match — unlike re-running the
|
|
7
|
+
* opencode postinstall, the flaky optional-dependency trap the startServer guard
|
|
8
|
+
* warns about.
|
|
9
|
+
*
|
|
10
|
+
* PURE copy — no PATH mutation — so it is targetable at ANY copy: the runtime
|
|
11
|
+
* path (engine-ensure) repairs the RUNNING copy; `amicus doctor --fix` repairs a
|
|
12
|
+
* foreign broken npx copy. Everything is injectable so tests never touch the real
|
|
13
|
+
* fs. Mirrors the Electron self-heal primitive (src/sidecar/electron-install.js).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const fsDefault = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
/** The running amicus package root (this file lives in <pkg>/src/utils). */
|
|
22
|
+
function runningPkgDir() {
|
|
23
|
+
return path.join(__dirname, '..', '..');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** First healthy install whose real path differs from the destination. */
|
|
27
|
+
function findDonor({ installs, destPkgDir, fs }) {
|
|
28
|
+
const norm = (p) => { try { return path.normalize(fs.realpathSync(p)); } catch { return path.normalize(p); } };
|
|
29
|
+
const destReal = norm(destPkgDir);
|
|
30
|
+
return installs.find((i) => i.engineOk && norm(i.pkgDir) !== destReal) || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The donor root (nested or hoisted) that actually holds the engine binary. */
|
|
34
|
+
function engineSourceRoot({ donorPkgDir, hasOpencodeBinary, opencodeRoots, fs }) {
|
|
35
|
+
for (const root of opencodeRoots({ pkgDir: donorPkgDir })) {
|
|
36
|
+
if (hasOpencodeBinary({ nodeModulesRoot: root, fs })) { return root; }
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Copy every opencode-* package dir + the .bin/opencode* shims source→dest. */
|
|
42
|
+
function copyEnginePackages({ sourceRoot, destRoot, fs }) {
|
|
43
|
+
const copied = [];
|
|
44
|
+
fs.mkdirSync(destRoot, { recursive: true });
|
|
45
|
+
for (const name of fs.readdirSync(sourceRoot)) {
|
|
46
|
+
if (!name.startsWith('opencode-')) { continue; }
|
|
47
|
+
fs.cpSync(path.join(sourceRoot, name), path.join(destRoot, name), { recursive: true, force: true });
|
|
48
|
+
copied.push(name);
|
|
49
|
+
}
|
|
50
|
+
// The engine resolver checks <root>/.bin/opencode on non-Windows, so carry the
|
|
51
|
+
// shims across too (relative symlinks resolve against the copied opencode-ai).
|
|
52
|
+
const srcBin = path.join(sourceRoot, '.bin');
|
|
53
|
+
let shims = [];
|
|
54
|
+
try { shims = fs.readdirSync(srcBin).filter((n) => n.startsWith('opencode')); } catch { shims = []; }
|
|
55
|
+
if (shims.length) {
|
|
56
|
+
const dstBin = path.join(destRoot, '.bin');
|
|
57
|
+
fs.mkdirSync(dstBin, { recursive: true });
|
|
58
|
+
for (const n of shims) {
|
|
59
|
+
fs.cpSync(path.join(srcBin, n), path.join(dstBin, n), { recursive: true, force: true });
|
|
60
|
+
copied.push(path.join('.bin', n));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return copied;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Copy the opencode engine into destPkgDir from a healthy sibling install.
|
|
68
|
+
* Never throws — every failure mode is a {repaired:false, ...} document.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} [opts]
|
|
71
|
+
* @param {string} [opts.destPkgDir] install to repair (default: running copy)
|
|
72
|
+
* @param {object} [opts.deps] injected { fs, scanEngineInstalls, hasOpencodeBinary, opencodeRoots, acquireLock }
|
|
73
|
+
* @returns {Promise<{repaired:boolean, reason?:string, contended?:boolean, donor?:string, copied?:string[]}>}
|
|
74
|
+
*/
|
|
75
|
+
async function repairEngine({ destPkgDir = runningPkgDir(), deps = {} } = {}) {
|
|
76
|
+
const fs = deps.fs || fsDefault;
|
|
77
|
+
const scanEngineInstalls = deps.scanEngineInstalls
|
|
78
|
+
|| (() => require('./engine-install-scan').scanEngineInstalls());
|
|
79
|
+
const hasOpencodeBinary = deps.hasOpencodeBinary || require('./path-setup').hasOpencodeBinary;
|
|
80
|
+
const opencodeRoots = deps.opencodeRoots || require('./path-setup').opencodeRoots;
|
|
81
|
+
const acquireLock = deps.acquireLock
|
|
82
|
+
|| ((o) => require('./engine-lock').acquireRepairLock({ ...o, fs }));
|
|
83
|
+
|
|
84
|
+
// Already healthy — nothing to do (ensureEngine fast-paths, but doctor --fix
|
|
85
|
+
// may call us directly on a copy a prior leg already healed).
|
|
86
|
+
if (hasOpencodeBinary({ pkgDir: destPkgDir, fs })) {
|
|
87
|
+
return { repaired: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { installs } = scanEngineInstalls();
|
|
91
|
+
const donor = findDonor({ installs, destPkgDir, fs });
|
|
92
|
+
if (!donor) {
|
|
93
|
+
return { repaired: false, reason: 'no healthy sibling install to copy the engine from' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sourceRoot = engineSourceRoot({ donorPkgDir: donor.pkgDir, hasOpencodeBinary, opencodeRoots, fs });
|
|
97
|
+
if (!sourceRoot) {
|
|
98
|
+
return { repaired: false, reason: `donor ${donor.pkgDir} has no resolvable engine root` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let lock;
|
|
102
|
+
try {
|
|
103
|
+
lock = acquireLock({ pkgDir: destPkgDir });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e && e.code === 'EEXIST') {
|
|
106
|
+
return { repaired: false, contended: true, reason: 'another engine repair is in progress' };
|
|
107
|
+
}
|
|
108
|
+
return { repaired: false, reason: `could not acquire repair lock: ${e && e.message}` };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const destRoot = path.join(destPkgDir, 'node_modules');
|
|
113
|
+
const copied = copyEnginePackages({ sourceRoot, destRoot, fs });
|
|
114
|
+
const repaired = !!hasOpencodeBinary({ pkgDir: destPkgDir, fs });
|
|
115
|
+
return repaired
|
|
116
|
+
? { repaired: true, donor: donor.pkgDir, copied }
|
|
117
|
+
: { repaired: false, reason: 'engine still missing after copy', donor: donor.pkgDir, copied };
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return { repaired: false, reason: `engine copy failed: ${e && e.message}` };
|
|
120
|
+
} finally {
|
|
121
|
+
try { lock.release(); } catch { /* ignore */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { repairEngine, findDonor, engineSourceRoot, copyEnginePackages, runningPkgDir };
|
|
@@ -226,6 +226,32 @@ function discoverCoworkMcps(configDir) {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The RAW amicus MCP registration config (never stripped) from either client —
|
|
231
|
+
* the first entry keyed 'amicus' or whose command/args resolve to an amicus MCP
|
|
232
|
+
* launch (isAmicusMcpConfig). Used by the doctor engine-scan to classify HOW the
|
|
233
|
+
* MCP launches (npx vs a fixed path). Distinct from hasAmicusRegistration (which
|
|
234
|
+
* returns only a boolean and reads Claude Code alone).
|
|
235
|
+
*
|
|
236
|
+
* @param {string} [claudeDir] - ~/.claude directory (for testing)
|
|
237
|
+
* @param {string} [claudeJsonPath] - ~/.claude.json path (for testing)
|
|
238
|
+
* @param {string} [coworkConfigDir] - Claude Desktop config dir (for testing)
|
|
239
|
+
* @returns {object|null} The amicus server config, or null if none is registered
|
|
240
|
+
*/
|
|
241
|
+
function readAmicusMcpConfig(claudeDir, claudeJsonPath, coworkConfigDir) {
|
|
242
|
+
const sources = [
|
|
243
|
+
readRawClaudeCodeMcpServers(claudeDir, claudeJsonPath),
|
|
244
|
+
discoverCoworkMcps(coworkConfigDir),
|
|
245
|
+
];
|
|
246
|
+
for (const servers of sources) {
|
|
247
|
+
if (!servers || typeof servers !== 'object') { continue; }
|
|
248
|
+
for (const [name, config] of Object.entries(servers)) {
|
|
249
|
+
if (name === 'amicus' || isAmicusMcpConfig(config)) { return config; }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
229
255
|
/**
|
|
230
256
|
* Discover MCP servers from the parent LLM's configuration.
|
|
231
257
|
*
|
|
@@ -248,5 +274,6 @@ module.exports = {
|
|
|
248
274
|
discoverClaudeCodeMcps,
|
|
249
275
|
discoverCoworkMcps,
|
|
250
276
|
hasAmicusRegistration,
|
|
277
|
+
readAmicusMcpConfig,
|
|
251
278
|
normalizeMcpJson
|
|
252
279
|
};
|
package/src/utils/path-setup.js
CHANGED
|
@@ -1,39 +1,84 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The node_modules roots that may hold the opencode engine sub-packages.
|
|
6
|
+
*
|
|
7
|
+
* npm only NESTS a dependency under <pkg>/node_modules when it cannot hoist it.
|
|
8
|
+
* `npm i -g amicus` nests opencode-windows-*; `npx -y amicus@latest` — which is
|
|
9
|
+
* exactly how postinstall registers the MCP server — HOISTS it to a sibling of
|
|
10
|
+
* amicus/, leaving <pkg>/node_modules nonexistent. Both are valid installs, so
|
|
11
|
+
* probe both roots rather than assuming a layout. Assuming the nested one made a
|
|
12
|
+
* present, runnable engine read as missing and threw engineMissing on every
|
|
13
|
+
* fanout leg under npx (#69).
|
|
14
|
+
*
|
|
15
|
+
* Order is search priority: nested (amicus's own copy) before hoisted.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} [deps] - test seams
|
|
18
|
+
* @param {string} [deps.nodeModulesRoot] - exact root override (wins outright)
|
|
19
|
+
* @param {string} [deps.pkgDir] - amicus package dir override
|
|
20
|
+
* @returns {string[]} candidate node_modules roots
|
|
21
|
+
*/
|
|
22
|
+
function opencodeRoots(deps = {}) {
|
|
23
|
+
if (deps.nodeModulesRoot) {
|
|
24
|
+
return [deps.nodeModulesRoot];
|
|
25
|
+
}
|
|
26
|
+
const pkgDir = deps.pkgDir || path.join(__dirname, '..', '..');
|
|
27
|
+
return [
|
|
28
|
+
path.join(pkgDir, 'node_modules'), // nested — npm i -g
|
|
29
|
+
path.dirname(pkgDir), // hoisted — npx/pnpm: the node_modules holding amicus
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
4
33
|
/**
|
|
5
34
|
* Ensures that the project's node_modules/.bin directory is included in the PATH,
|
|
6
35
|
* and on Windows also adds the platform-specific native opencode binary directory
|
|
7
36
|
* so that `spawn('opencode', ...)` without shell:true can resolve the .exe.
|
|
8
37
|
* The OpenCode SDK spawns the 'opencode' command, and this ensures it can be found.
|
|
38
|
+
* Walks every candidate root so hoisted installs resolve too (#69).
|
|
39
|
+
*
|
|
40
|
+
* PATH is a first-match search, so the array below IS the priority order — built
|
|
41
|
+
* highest-priority-first, then prepended as ONE group. Two orderings matter and
|
|
42
|
+
* both were once inverted by prepending one dir at a time (LIFO reverses intent):
|
|
43
|
+
* - default (AVX2) before -baseline: an AVX2 machine must run the fast build,
|
|
44
|
+
* with baseline only as the older-CPU fallback.
|
|
45
|
+
* - every native .exe dir before any .bin: on Windows .bin holds a .cmd shim
|
|
46
|
+
* that bare spawn() cannot execute, so a real .exe must always win — even a
|
|
47
|
+
* .exe in the other candidate root beats a shim in this one.
|
|
48
|
+
* Hence the three tiers: all defaults, then all baselines, then all .bin — each
|
|
49
|
+
* tier spanning every root in opencodeRoots() priority order.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} [deps] - test seams, forwarded to opencodeRoots()
|
|
52
|
+
* @param {string} [deps.nodeModulesRoot] - exact root override (wins outright)
|
|
53
|
+
* @param {string} [deps.pkgDir] - amicus package dir override
|
|
9
54
|
*/
|
|
10
|
-
function ensureNodeModulesBinInPath() {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
55
|
+
function ensureNodeModulesBinInPath(deps = {}) {
|
|
56
|
+
const roots = opencodeRoots(deps);
|
|
57
|
+
const dirs = [];
|
|
13
58
|
|
|
14
|
-
if (!process.env.PATH.includes(nodeModulesBin)) {
|
|
15
|
-
process.env.PATH = `${nodeModulesBin}${path.delimiter}${process.env.PATH}`;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// On Windows, Node's spawn() does not execute .cmd shims without shell:true.
|
|
19
|
-
// Add the platform-specific native binary directory so `opencode` resolves
|
|
20
|
-
// to opencode.exe directly (Windows searches PATHEXT-aware when .exe is present).
|
|
21
59
|
if (os.platform() === 'win32') {
|
|
22
60
|
const archMap = { x64: 'x64', arm64: 'arm64' };
|
|
23
61
|
const arch = archMap[os.arch()] || os.arch();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
62
|
+
// Tier 1: default (AVX2) build in every root.
|
|
63
|
+
for (const root of roots) {
|
|
64
|
+
dirs.push(path.join(root, `opencode-windows-${arch}`, 'bin'));
|
|
27
65
|
}
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const nativeBinBaseline = path.join(nodeModulesRoot, `opencode-windows-${arch}-baseline`, 'bin');
|
|
33
|
-
if (!process.env.PATH.includes(nativeBinBaseline)) {
|
|
34
|
-
process.env.PATH = `${nativeBinBaseline}${path.delimiter}${process.env.PATH}`;
|
|
66
|
+
// Tier 2: -baseline (pre-AVX2) fallback in every root — searched only after
|
|
67
|
+
// every default build. Do not delete these entries as "dead code".
|
|
68
|
+
for (const root of roots) {
|
|
69
|
+
dirs.push(path.join(root, `opencode-windows-${arch}-baseline`, 'bin'));
|
|
35
70
|
}
|
|
36
71
|
}
|
|
72
|
+
// Tier 3: node_modules/.bin in every root, last (its shims spawn() cannot run).
|
|
73
|
+
for (const root of roots) {
|
|
74
|
+
dirs.push(path.join(root, '.bin'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Prepend the not-yet-present dirs as one group, preserving the order above.
|
|
78
|
+
const missing = dirs.filter((dir) => !process.env.PATH.includes(dir));
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
process.env.PATH = `${missing.join(path.delimiter)}${path.delimiter}${process.env.PATH}`;
|
|
81
|
+
}
|
|
37
82
|
}
|
|
38
83
|
|
|
39
84
|
/**
|
|
@@ -48,6 +93,8 @@ function ensureNodeModulesBinInPath() {
|
|
|
48
93
|
*
|
|
49
94
|
* Mirrors the PATH resolution order: on Windows the platform sub-package (and
|
|
50
95
|
* its -baseline variant) bin/opencode.exe; elsewhere node_modules/.bin/opencode.
|
|
96
|
+
* Probes every candidate root (nested AND hoisted — see opencodeRoots), so a
|
|
97
|
+
* layout npm chose for us can never masquerade as a missing engine (#69).
|
|
51
98
|
* Never throws — a probe failure reads as not-found.
|
|
52
99
|
*
|
|
53
100
|
* @param {object} [deps] - test seams
|
|
@@ -55,19 +102,24 @@ function ensureNodeModulesBinInPath() {
|
|
|
55
102
|
* @param {string} [deps.platform] - process.platform override
|
|
56
103
|
* @param {string} [deps.arch] - os.arch() override
|
|
57
104
|
* @param {string} [deps.nodeModulesRoot] - node_modules root override
|
|
105
|
+
* @param {string} [deps.pkgDir] - amicus package dir override
|
|
58
106
|
* @returns {boolean} true only when a real opencode binary resolves on disk
|
|
59
107
|
*/
|
|
60
108
|
function hasOpencodeBinary(deps = {}) {
|
|
61
109
|
const fs = deps.fs || require('fs');
|
|
62
110
|
const platform = deps.platform || process.platform;
|
|
63
111
|
const arch = deps.arch || os.arch();
|
|
64
|
-
const root = deps.nodeModulesRoot || path.join(__dirname, '..', '..', 'node_modules');
|
|
65
112
|
|
|
66
113
|
const a = arch === 'arm64' ? 'arm64' : 'x64';
|
|
67
|
-
const candidates =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
114
|
+
const candidates = [];
|
|
115
|
+
for (const root of opencodeRoots(deps)) {
|
|
116
|
+
if (platform === 'win32') {
|
|
117
|
+
candidates.push(path.join(root, `opencode-windows-${a}`, 'bin', 'opencode.exe'));
|
|
118
|
+
candidates.push(path.join(root, `opencode-windows-${a}-baseline`, 'bin', 'opencode.exe'));
|
|
119
|
+
} else {
|
|
120
|
+
candidates.push(path.join(root, '.bin', 'opencode'));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
71
123
|
|
|
72
124
|
return candidates.some((p) => { try { return fs.existsSync(p); } catch (_e) { return false; } });
|
|
73
125
|
}
|
|
@@ -75,4 +127,5 @@ function hasOpencodeBinary(deps = {}) {
|
|
|
75
127
|
module.exports = {
|
|
76
128
|
ensureNodeModulesBinInPath,
|
|
77
129
|
hasOpencodeBinary,
|
|
130
|
+
opencodeRoots,
|
|
78
131
|
};
|