amicus 3.2.1 → 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 +17 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/cli-handlers-doctor.js +3 -1
- package/src/opencode-client.js +18 -13
- package/src/utils/doctor-engine-check.js +34 -1
- package/src/utils/engine-ensure.js +72 -0
- package/src/utils/engine-lock.js +94 -0
- package/src/utils/engine-repair.js +125 -0
|
@@ -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,23 @@ 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
|
+
|
|
8
25
|
## [3.2.1] - 2026-07-17
|
|
9
26
|
|
|
10
27
|
### Fixed
|
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/",
|
|
@@ -42,6 +42,8 @@ function realDeps() {
|
|
|
42
42
|
// engine-mcp check: probe the engine in every install that could serve the
|
|
43
43
|
// MCP (running/global/npx-cache), not just the one doctor runs from (#1).
|
|
44
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),
|
|
45
47
|
getElectronPath: () => require('./sidecar/interactive-process').getElectronPath(),
|
|
46
48
|
// #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
|
|
47
49
|
// stays separate; repair only runs when fix is requested.
|
|
@@ -150,7 +152,7 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
150
152
|
// Cross-install: verify the engine in the npx-cache copies the MCP actually
|
|
151
153
|
// launches (`npx -y amicus@latest mcp`), so a green 'opencode-bin' (the running
|
|
152
154
|
// install) can't hide a broken copy the MCP would spawn (bug report #1/#4).
|
|
153
|
-
checks.push(
|
|
155
|
+
checks.push(await guardAsync('engine-mcp', 'OpenCode engine (MCP launch path)', () => engineCheck.evaluateEngineMcp(d)));
|
|
154
156
|
|
|
155
157
|
checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
|
|
156
158
|
if (d.getElectronPath()) {
|
package/src/opencode-client.js
CHANGED
|
@@ -625,22 +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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
+
}
|
|
644
649
|
}
|
|
645
650
|
|
|
646
651
|
const createOpencodeServer = await getCreateOpencodeServer();
|
|
@@ -68,4 +68,37 @@ function evaluateEngineInstalls(d) {
|
|
|
68
68
|
return { id, name, status, message: `${lead}: ${detail}`, hint: HINTS.reinstallEngineAv };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
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,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 };
|