amicus 1.7.1 → 1.7.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 +25 -0
- package/package.json +1 -1
- package/scripts/postinstall.js +15 -2
- package/src/cli-handlers-doctor.js +15 -14
- package/src/opencode-client.js +14 -0
- package/src/sidecar/electron-install.js +76 -20
- package/src/sidecar/electron-quarantine.js +70 -0
- package/src/utils/path-setup.js +37 -0
- package/src/utils/remediation-hints.js +18 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.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": { "name": "Christian Wagner" },
|
|
6
6
|
"homepage": "https://bourbondog.github.io/amicus/",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.2] - 2026-06-30
|
|
9
|
+
|
|
10
|
+
The Electron self-heal now tells the truth, heals the cases it can, and clearly explains the ones it can't.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The Electron self-heal no longer claims success when it didn't heal.** `repairElectron`'s
|
|
14
|
+
installer-fallback path always reported the GUI as "provisioned"/"fixed" even when the binary wasn't
|
|
15
|
+
actually on disk — so `amicus doctor --fix` and the install-time prewarm could falsely report
|
|
16
|
+
success. Every self-heal / provision path now declares success **only** when the Electron binary is
|
|
17
|
+
verified present on disk.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **The GUI repair is now controlled and introspectable.** Instead of blindly re-running Electron's own
|
|
21
|
+
installer (the postinstall npm had already silently suppressed), the repair downloads and extracts the
|
|
22
|
+
binary itself (via `@electron/get`) and verifies the result; a corrupt cached download is cleared and
|
|
23
|
+
re-fetched once.
|
|
24
|
+
- **Antivirus quarantine is detected and explained, not retried forever.** When Windows Defender / AV
|
|
25
|
+
removes `electron.exe` right after extraction (the common Windows failure), amicus now tells you to
|
|
26
|
+
allow-list the binary and re-run `amicus doctor --fix`, instead of silently looping a repair that
|
|
27
|
+
cannot win.
|
|
28
|
+
- **Clear, actionable error when the OpenCode engine binary is missing.** The engine ships via
|
|
29
|
+
per-platform binaries that npm can silently skip (or AV can quarantine); when it's absent, amicus now
|
|
30
|
+
surfaces a specific instruction (run `amicus doctor`, reinstall, allow-list `opencode.exe`) instead of
|
|
31
|
+
an opaque spawn failure.
|
|
32
|
+
|
|
8
33
|
## [1.7.1] - 2026-06-30
|
|
9
34
|
|
|
10
35
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.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
|
"keywords": [
|
|
6
6
|
"claude",
|
package/scripts/postinstall.js
CHANGED
|
@@ -216,16 +216,29 @@ async function provisionElectron(deps = {}) {
|
|
|
216
216
|
if (process.env.AMICUS_PREFETCH_ELECTRON === '1') {
|
|
217
217
|
console.log('[amicus] AMICUS_PREFETCH_ELECTRON=1 — prewarming the Electron GUI binary (may download)...');
|
|
218
218
|
const forced = await _repair({ force: true });
|
|
219
|
-
if (forced &&
|
|
219
|
+
if (forced && forced.repaired) {
|
|
220
220
|
console.log('[amicus] Electron GUI binary prewarmed.');
|
|
221
221
|
return;
|
|
222
222
|
}
|
|
223
|
+
if (forced && forced.quarantined) {
|
|
224
|
+
console.warn(`[amicus] Note: the Electron GUI binary could not be installed — ${forced.reason || 'antivirus quarantine.'}`);
|
|
225
|
+
console.warn('[amicus] Headless runs and the council already work without the GUI.');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
223
228
|
console.warn('[amicus] Note: Electron prewarm did not complete now — the GUI provisions on first use.');
|
|
224
229
|
return;
|
|
225
230
|
}
|
|
226
231
|
|
|
227
232
|
const result = await _repair({ cacheOnly: true, timeoutMs: PROVISION_TIMEOUT_MS });
|
|
228
|
-
if (result &&
|
|
233
|
+
if (result && result.repaired) { return; }
|
|
234
|
+
// AV quarantine (electron.exe deleted right after extract) needs ACTION, not
|
|
235
|
+
// a generic "provisions on first use" notice — re-extracting can never win,
|
|
236
|
+
// so print the allow-list instruction verbatim instead. (No retry loop.)
|
|
237
|
+
if (result && result.quarantined) {
|
|
238
|
+
console.warn(`[amicus] Note: the Electron GUI binary could not be installed — ${result.reason || 'antivirus quarantine.'}`);
|
|
239
|
+
console.warn('[amicus] Headless runs and the council already work without the GUI.');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
229
242
|
// No cache hit (deferred), contended, or otherwise not provisioned now.
|
|
230
243
|
console.warn('[amicus] Note: the Electron GUI binary is not provisioned yet — it will download on first use of the interactive GUI / setup-wizard.');
|
|
231
244
|
console.warn(`[amicus] Headless runs and the council already work. To provision the GUI now: ${HINTS.doctorFix}`);
|
|
@@ -29,14 +29,10 @@ function realDeps() {
|
|
|
29
29
|
collectAliasSources: () => require('./utils/alias-audit').collectAliasSources(),
|
|
30
30
|
findStaleAliases: (s, c) => require('./utils/alias-audit').findStaleAliases(s, c),
|
|
31
31
|
hasOpencodeBinary: () => {
|
|
32
|
-
|
|
32
|
+
// Single source of truth shared with the runtime server-start guard.
|
|
33
|
+
const { ensureNodeModulesBinInPath, hasOpencodeBinary } = require('./utils/path-setup');
|
|
33
34
|
ensureNodeModulesBinInPath();
|
|
34
|
-
|
|
35
|
-
const candidates = process.platform === 'win32'
|
|
36
|
-
? [path.join(root, `opencode-windows-${os.arch() === 'arm64' ? 'arm64' : 'x64'}`, 'bin', 'opencode.exe'),
|
|
37
|
-
path.join(root, `opencode-windows-${os.arch() === 'arm64' ? 'arm64' : 'x64'}-baseline`, 'bin', 'opencode.exe')]
|
|
38
|
-
: [path.join(root, '.bin', 'opencode')];
|
|
39
|
-
return candidates.some(p => fs.existsSync(p));
|
|
35
|
+
return hasOpencodeBinary();
|
|
40
36
|
},
|
|
41
37
|
getElectronPath: () => require('./sidecar/interactive').getElectronPath(),
|
|
42
38
|
// #56: self-heal primitive for `doctor --fix`. Pure probe (getElectronPath)
|
|
@@ -128,7 +124,7 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
128
124
|
checks.push(guard('opencode-bin', 'OpenCode binary', () => (
|
|
129
125
|
d.hasOpencodeBinary()
|
|
130
126
|
? { id: 'opencode-bin', name: 'OpenCode binary', status: 'ok', message: 'found', hint: null }
|
|
131
|
-
: { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.
|
|
127
|
+
: { id: 'opencode-bin', name: 'OpenCode binary', status: 'error', message: 'not found', hint: HINTS.reinstallEngineAv }
|
|
132
128
|
)));
|
|
133
129
|
|
|
134
130
|
checks.push(await guardAsync('electron', 'Electron (interactive GUI)', async () => {
|
|
@@ -147,15 +143,20 @@ async function runDoctorChecks(depsOverride = {}) {
|
|
|
147
143
|
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `repair failed: ${e.message} — headless still works`, hint: HINTS.doctorFix };
|
|
148
144
|
}
|
|
149
145
|
res = res || {};
|
|
150
|
-
if (res.repaired
|
|
146
|
+
if (res.repaired) {
|
|
151
147
|
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'ok', message: 'installed (self-healed)', hint: null };
|
|
152
148
|
}
|
|
153
149
|
const why = res.reason ? ` — ${res.reason}` : '';
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
150
|
+
// Quarantine (AV deleted electron.exe post-extract) is NOT a deferral and
|
|
151
|
+
// must NEVER be silently retried: surface the allow-list instruction as a
|
|
152
|
+
// WARN and STOP. No re-run of repairElectron here (no loop).
|
|
153
|
+
const detail = res.quarantined
|
|
154
|
+
? `antivirus quarantine${why}`
|
|
155
|
+
: res.deferred
|
|
156
|
+
? `deferred${why}`
|
|
157
|
+
: res.contended
|
|
158
|
+
? `repair already in progress${why}`
|
|
159
|
+
: `not provisioned${why}`;
|
|
159
160
|
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: `${detail} — headless still works`, hint: HINTS.doctorFix };
|
|
160
161
|
}
|
|
161
162
|
return { id: 'electron', name: 'Electron (interactive GUI)', status: 'warn', message: 'not installed — headless still works', hint: HINTS.doctorFix };
|
package/src/opencode-client.js
CHANGED
|
@@ -565,6 +565,20 @@ function buildServerHandle(sdkServer, deps = {}) {
|
|
|
565
565
|
* @returns {Promise<{client: object, server: {url: string, close: Function}}>}
|
|
566
566
|
*/
|
|
567
567
|
async function startServer(options = {}) {
|
|
568
|
+
// Fail fast with a CLEAR, actionable error when the opencode engine binary
|
|
569
|
+
// is absent (skipped optionalDependency install or AV quarantine). Without
|
|
570
|
+
// this, createOpencodeServer → spawn('opencode') fails with an opaque ENOENT.
|
|
571
|
+
// Checked BEFORE the SDK is loaded so the message is the first thing the user
|
|
572
|
+
// sees. `_hasOpencodeBinary` is a test seam; default is the shared resolver
|
|
573
|
+
// (single source of truth with `amicus doctor`). NOTE: this does NOT
|
|
574
|
+
// auto-repair — re-running the opencode postinstall is the same flaky trap.
|
|
575
|
+
const hasOpencodeBinary = options._hasOpencodeBinary
|
|
576
|
+
|| require('./utils/path-setup').hasOpencodeBinary;
|
|
577
|
+
if (!hasOpencodeBinary()) {
|
|
578
|
+
const HINTS = require('./utils/remediation-hints');
|
|
579
|
+
throw new Error(HINTS.engineMissing);
|
|
580
|
+
}
|
|
581
|
+
|
|
568
582
|
const createOpencodeServer = await getCreateOpencodeServer();
|
|
569
583
|
const serverOptions = buildServerOptions(options);
|
|
570
584
|
|
|
@@ -15,9 +15,7 @@
|
|
|
15
15
|
* node_modules/electron/dist/<exe> -> the actual binary
|
|
16
16
|
* #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
|
|
17
17
|
* instead of <pkg>/dist (mirrors electron/index.js + install.js semantics).
|
|
18
|
-
*
|
|
19
|
-
* Cache layout (@electron/get):
|
|
20
|
-
* <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
|
|
18
|
+
* Cache layout (@electron/get): <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
|
|
21
19
|
*/
|
|
22
20
|
|
|
23
21
|
'use strict';
|
|
@@ -28,6 +26,7 @@ const os = require('os');
|
|
|
28
26
|
const { spawnSync } = require('child_process');
|
|
29
27
|
|
|
30
28
|
const { resolveCacheRoots } = require('./electron-cache');
|
|
29
|
+
const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
|
|
31
30
|
|
|
32
31
|
/** Default on-disk location of the installed electron package. */
|
|
33
32
|
function defaultElectronDir() {
|
|
@@ -116,15 +115,6 @@ function cachedZip({ version, platform = process.platform, arch = process.arch,
|
|
|
116
115
|
return null;
|
|
117
116
|
}
|
|
118
117
|
|
|
119
|
-
/** Quarantine / AV note appended to win32 deferral reasons. */
|
|
120
|
-
function avHint(platform) {
|
|
121
|
-
if (platform === 'win32') {
|
|
122
|
-
return ' Windows Defender / antivirus may have quarantined electron.exe; '
|
|
123
|
-
+ 'allow it and re-run, or reinstall.';
|
|
124
|
-
}
|
|
125
|
-
return '';
|
|
126
|
-
}
|
|
127
|
-
|
|
128
118
|
/**
|
|
129
119
|
* Single-flight lockfile: only one caller may extract/install at a time, so
|
|
130
120
|
* concurrent callers don't double-extract (Windows EBUSY). Throws an
|
|
@@ -154,6 +144,41 @@ async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
|
|
|
154
144
|
writePathTxt({ electronDir, platform, fs });
|
|
155
145
|
}
|
|
156
146
|
|
|
147
|
+
/** Best-effort cache root for downloadArtifact (first resolved root). */
|
|
148
|
+
function cacheRootFor(env = process.env) {
|
|
149
|
+
return resolveCacheRoots(env)[0];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
|
|
154
|
+
* api install.js uses (downloadArtifact, force:true), extract offline, and let
|
|
155
|
+
* the caller verify isElectronUsable(). No blind install.js spawn.
|
|
156
|
+
* @returns {Promise<void>}
|
|
157
|
+
*/
|
|
158
|
+
async function controlledProvision({
|
|
159
|
+
electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env,
|
|
160
|
+
}) {
|
|
161
|
+
const zip = await downloadArtifact({
|
|
162
|
+
version,
|
|
163
|
+
artifactName: 'electron',
|
|
164
|
+
force: true,
|
|
165
|
+
cacheRoot: cacheRootFor(env),
|
|
166
|
+
platform,
|
|
167
|
+
arch,
|
|
168
|
+
checksums: undefined,
|
|
169
|
+
});
|
|
170
|
+
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
|
|
174
|
+
function verifyExtractOutcome({ electronDir, platform, fs }) {
|
|
175
|
+
return verifyQuarantine({
|
|
176
|
+
isElectronUsable: () => isElectronUsable({ electronDir, platform, fs }),
|
|
177
|
+
resolveExe: () => resolveElectronBinary({ electronDir, platform, fs }),
|
|
178
|
+
platform,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
157
182
|
/** Drive electron's own install.js with force_no_cache semantics. */
|
|
158
183
|
function runInstaller({ electronDir, force, spawn }) {
|
|
159
184
|
const installScript = path.join(electronDir, 'install.js');
|
|
@@ -190,6 +215,7 @@ async function repairElectron({
|
|
|
190
215
|
const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs }));
|
|
191
216
|
const findZip = deps.cachedZip || ((o) => cachedZip(o));
|
|
192
217
|
const acquireLock = deps.acquireLock || ((o) => defaultAcquireLock({ ...o, fs }));
|
|
218
|
+
const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
|
|
193
219
|
|
|
194
220
|
if (!version) {
|
|
195
221
|
try {
|
|
@@ -214,20 +240,50 @@ async function repairElectron({
|
|
|
214
240
|
// Attempt 1: extract from cache (always preferred, fully offline).
|
|
215
241
|
const zip = findZip({ version, platform, arch, env: process.env, fs });
|
|
216
242
|
if (zip) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
243
|
+
try {
|
|
244
|
+
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
245
|
+
// Non-throwing extract w/ absent exe = the AV-quarantine signature.
|
|
246
|
+
return verifyExtractOutcome({ electronDir, platform, fs });
|
|
247
|
+
} catch (extractErr) {
|
|
248
|
+
// Corrupt cached artifact: delete the bad zip so it can't poison the
|
|
249
|
+
// cache, then fall through to a forced fresh download (unless offline).
|
|
250
|
+
try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
|
|
251
|
+
if (cacheOnly) {
|
|
252
|
+
return {
|
|
253
|
+
repaired: false,
|
|
254
|
+
reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
// else: drop into the controlled download below.
|
|
258
|
+
}
|
|
259
|
+
} else if (cacheOnly) {
|
|
222
260
|
return {
|
|
223
261
|
deferred: true,
|
|
224
262
|
reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
|
|
225
263
|
};
|
|
226
264
|
}
|
|
227
265
|
|
|
228
|
-
// Attempt 2:
|
|
229
|
-
|
|
230
|
-
|
|
266
|
+
// Attempt 2 (online): CONTROLLED download+extract instead of a blind
|
|
267
|
+
// install.js spawn — fetch via the SAME @electron/get api install.js uses,
|
|
268
|
+
// extract offline, then report the REAL usability. A structurally-successful
|
|
269
|
+
// download that produced no usable exe is a FAILURE (no false success; #53).
|
|
270
|
+
let controlledExtracted = false;
|
|
271
|
+
try {
|
|
272
|
+
await controlledProvision({
|
|
273
|
+
electronDir, platform, arch, version, downloadArtifact, extract, fs, env: process.env,
|
|
274
|
+
});
|
|
275
|
+
controlledExtracted = true; // download + extract returned without throwing
|
|
276
|
+
} catch {
|
|
277
|
+
// Controlled download/extract failed (network, checksum, unzip). Try the
|
|
278
|
+
// installer as a LAST resort — it can NEVER short-circuit the honest
|
|
279
|
+
// verify below; we always return isElectronUsable().
|
|
280
|
+
try { runInstaller({ electronDir, force, spawn }); } catch { /* ignore */ }
|
|
281
|
+
}
|
|
282
|
+
// A NON-throwing controlled extract that left no usable exe is the
|
|
283
|
+
// AV-quarantine signature — surface it actionably (no false success, no
|
|
284
|
+
// loop). A controlled FAILURE only reports plain repaired:false.
|
|
285
|
+
if (controlledExtracted) { return verifyExtractOutcome({ electronDir, platform, fs }); }
|
|
286
|
+
return { repaired: isElectronUsable({ electronDir, platform, fs }) };
|
|
231
287
|
} finally {
|
|
232
288
|
try { lock.release(); } catch { /* ignore */ }
|
|
233
289
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AV / antivirus quarantine detection for the electron self-heal (#53).
|
|
3
|
+
*
|
|
4
|
+
* The DOMINANT real-world Windows failure: Windows Defender / antivirus deletes
|
|
5
|
+
* electron.exe right AFTER each extract. No re-extract can ever win that race —
|
|
6
|
+
* the user MUST allow-list the binary. This module owns the quarantine MESSAGE
|
|
7
|
+
* + the post-extract VERIFY so electron-install.js stays under the size gate and
|
|
8
|
+
* the wording lives in one place.
|
|
9
|
+
*
|
|
10
|
+
* Split out of src/sidecar/electron-install.js (kept <300 lines).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
/** Quarantine / AV note appended to win32 deferral reasons. */
|
|
16
|
+
function avHint(platform) {
|
|
17
|
+
if (platform === 'win32') {
|
|
18
|
+
return ' Windows Defender / antivirus may have quarantined electron.exe; '
|
|
19
|
+
+ 'allow it and re-run, or reinstall.';
|
|
20
|
+
}
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Actionable quarantine instruction. AV / Windows Defender deletes electron.exe
|
|
26
|
+
* right AFTER each extract, so no re-extract can win — the user MUST allow-list
|
|
27
|
+
* the binary. We name the exact path to allow and point at the in-place
|
|
28
|
+
* self-heal re-run (NOT a reinstall, which would loop). win32-specific; other
|
|
29
|
+
* platforms get the generic avHint fallback.
|
|
30
|
+
*/
|
|
31
|
+
function quarantineReason({ platform, exePath }) {
|
|
32
|
+
if (platform === 'win32') {
|
|
33
|
+
const target = exePath || 'node_modules/electron/dist/electron.exe';
|
|
34
|
+
return 'electron.exe was removed right after it was extracted — antivirus '
|
|
35
|
+
+ '(e.g. Windows Defender) likely quarantined it. Allow '
|
|
36
|
+
+ `${target} in your AV, then run amicus doctor --fix.`;
|
|
37
|
+
}
|
|
38
|
+
return 'The electron binary vanished right after extraction — your antivirus '
|
|
39
|
+
+ `may have quarantined it. Allow it, then run amicus doctor --fix.${avHint(platform)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Verify the outcome of a NON-throwing extract (cache OR controlled download).
|
|
44
|
+
*
|
|
45
|
+
* THE QUARANTINE SIGNATURE: the extract did not throw, yet isElectronUsable()
|
|
46
|
+
* is STILL false — the exe was written then vanished. On win32 that is almost
|
|
47
|
+
* always AV / Windows Defender quarantining electron.exe, and NO re-extract can
|
|
48
|
+
* win. We report { repaired:false, quarantined:true, reason } with an ACTIONABLE
|
|
49
|
+
* allow-list instruction — NEVER a false success, and the caller MUST NOT
|
|
50
|
+
* auto-retry (a quarantine loop is the bug we're killing).
|
|
51
|
+
*
|
|
52
|
+
* When the exe IS present, this is a clean { repaired:true }.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {() => boolean} opts.isElectronUsable bound usability probe.
|
|
56
|
+
* @param {() => string} opts.resolveExe resolves the on-disk exe path.
|
|
57
|
+
* @param {string} opts.platform
|
|
58
|
+
*/
|
|
59
|
+
function verifyExtractOutcome({ isElectronUsable, resolveExe, platform }) {
|
|
60
|
+
if (isElectronUsable()) {
|
|
61
|
+
return { repaired: true };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
repaired: false,
|
|
65
|
+
quarantined: true,
|
|
66
|
+
reason: quarantineReason({ platform, exePath: resolveExe() }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { avHint, quarantineReason, verifyExtractOutcome };
|
package/src/utils/path-setup.js
CHANGED
|
@@ -36,6 +36,43 @@ function ensureNodeModulesBinInPath() {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolve whether the opencode engine binary actually exists on disk.
|
|
41
|
+
*
|
|
42
|
+
* opencode-ai ships the engine via per-platform binary sub-packages declared as
|
|
43
|
+
* optionalDependencies, so a skipped install or an antivirus quarantine can
|
|
44
|
+
* leave the .exe silently absent — and then `spawn('opencode')` fails with an
|
|
45
|
+
* opaque ENOENT. This is the SINGLE source of truth shared by `amicus doctor`
|
|
46
|
+
* (the opencode-bin check) and the runtime server-start guard, so both agree on
|
|
47
|
+
* what "the binary is present" means.
|
|
48
|
+
*
|
|
49
|
+
* Mirrors the PATH resolution order: on Windows the platform sub-package (and
|
|
50
|
+
* its -baseline variant) bin/opencode.exe; elsewhere node_modules/.bin/opencode.
|
|
51
|
+
* Never throws — a probe failure reads as not-found.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} [deps] - test seams
|
|
54
|
+
* @param {object} [deps.fs] - fs module (existsSync)
|
|
55
|
+
* @param {string} [deps.platform] - process.platform override
|
|
56
|
+
* @param {string} [deps.arch] - os.arch() override
|
|
57
|
+
* @param {string} [deps.nodeModulesRoot] - node_modules root override
|
|
58
|
+
* @returns {boolean} true only when a real opencode binary resolves on disk
|
|
59
|
+
*/
|
|
60
|
+
function hasOpencodeBinary(deps = {}) {
|
|
61
|
+
const fs = deps.fs || require('fs');
|
|
62
|
+
const platform = deps.platform || process.platform;
|
|
63
|
+
const arch = deps.arch || os.arch();
|
|
64
|
+
const root = deps.nodeModulesRoot || path.join(__dirname, '..', '..', 'node_modules');
|
|
65
|
+
|
|
66
|
+
const a = arch === 'arm64' ? 'arm64' : 'x64';
|
|
67
|
+
const candidates = platform === 'win32'
|
|
68
|
+
? [path.join(root, `opencode-windows-${a}`, 'bin', 'opencode.exe'),
|
|
69
|
+
path.join(root, `opencode-windows-${a}-baseline`, 'bin', 'opencode.exe')]
|
|
70
|
+
: [path.join(root, '.bin', 'opencode')];
|
|
71
|
+
|
|
72
|
+
return candidates.some((p) => { try { return fs.existsSync(p); } catch (_e) { return false; } });
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
module.exports = {
|
|
40
76
|
ensureNodeModulesBinInPath,
|
|
77
|
+
hasOpencodeBinary,
|
|
41
78
|
};
|
|
@@ -26,6 +26,24 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
26
26
|
reinstallEngine:
|
|
27
27
|
'npm install -g amicus (a transient install error can roll back the engine binaries — re-run, or: npm cache clean --force && npm install -g amicus)',
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Engine binary missing/rolled back AND possibly AV-quarantined. The doctor
|
|
31
|
+
* opencode-bin hint: combines the transient-rollback reinstall guidance with
|
|
32
|
+
* the Windows-Defender allow-list note, matching the electron avHint contract.
|
|
33
|
+
*/
|
|
34
|
+
reinstallEngineAv:
|
|
35
|
+
'npm install -g amicus (a transient install error can roll back the engine binaries — re-run, or: npm cache clean --force && npm install -g amicus). '
|
|
36
|
+
+ 'If your antivirus (e.g. Windows Defender) quarantined opencode.exe, allow-list it first, then reinstall.',
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Runtime server-start failure when the opencode engine binary does not
|
|
40
|
+
* resolve on disk. One clear, actionable message replacing the opaque
|
|
41
|
+
* spawn ENOENT — surfaced by startServer (the missing-binary boundary).
|
|
42
|
+
*/
|
|
43
|
+
engineMissing:
|
|
44
|
+
'OpenCode engine binary not found — it was likely skipped during install or quarantined by antivirus. '
|
|
45
|
+
+ 'Run "amicus doctor", reinstall with "npm i -g amicus", and allow-list opencode.exe in your antivirus.',
|
|
46
|
+
|
|
29
47
|
/** Electron absent — reinstall to add the interactive GUI (headless still works). */
|
|
30
48
|
reinstallElectron: 'npm install -g amicus (reinstall to add Electron)',
|
|
31
49
|
|