amicus 1.7.3 → 1.7.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
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,21 @@ All notable changes to Amicus are documented here. Format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.4] - 2026-06-30
9
+
10
+ ### Fixed
11
+ - **The Electron GUI self-heal survives a stalled `extract-zip` on Node 24.** On some Node 24 boxes the
12
+ bundled `extract-zip@2.0.1` (its latest release — it cannot be bumped) stalls mid-extract: its promise
13
+ never resolves *and* never rejects. Because the self-heal `await`s it, the event loop drains and the
14
+ process exits `0` with a half-extracted `dist/` and **no `electron.exe`** — so the repair looked like it
15
+ "did nothing." Extraction is now hardened two ways: `extract-zip` is bounded by an idle + max timer (a
16
+ stall becomes a caught error instead of a silent hang, and the live timer prevents the premature exit),
17
+ and if it stalls, throws, or produces no files, amicus falls back to a **native OS unzip** (Windows:
18
+ bundled `bsdtar`, then PowerShell `Expand-Archive`; macOS: `ditto`, then `unzip`; Linux: `unzip`, then
19
+ `tar`) — each verified to extract the exact Electron zip that `extract-zip` choked on. Success is still
20
+ reported **only** when the real binary lands on disk (the existing exe-stat verify is unchanged), so no
21
+ path can claim a false repair.
22
+
8
23
  ## [1.7.3] - 2026-06-30
9
24
 
10
25
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.3",
3
+ "version": "1.7.4",
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",
@@ -27,6 +27,12 @@ const { spawnSync } = require('child_process');
27
27
  const { resolveCacheRoots } = require('./electron-cache');
28
28
  const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
29
29
  const { acquireRepairLock } = require('./electron-lock');
30
+ const { robustExtract } = require('./unzip');
31
+
32
+ /** Self-heal progress line to stderr (visible during first-GUI provision). */
33
+ function stderrLog(msg) {
34
+ try { process.stderr.write(`${msg}\n`); } catch { /* stderr closed */ }
35
+ }
30
36
 
31
37
  /** Default on-disk location of the installed electron package. */
32
38
  function defaultElectronDir() {
@@ -199,7 +205,9 @@ async function repairElectron({
199
205
  deps = {},
200
206
  } = {}) {
201
207
  const fs = deps.fs || fsDefault;
202
- const extract = deps.extract || require('extract-zip');
208
+ // Default extract: extract-zip bounded (idle/max) + native-unzip fallback (extract-zip-node24 stall).
209
+ const extract = deps.extract
210
+ || ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
203
211
  // Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
204
212
  // provision that reaches runInstaller without an explicit timeoutMs can't hang
205
213
  // the holder — the caller's timeoutMs still wins when provided.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Robust unzip for the electron self-heal (#53 follow-up; extract-zip-node24).
3
+ *
4
+ * FIELD BUG: on some Node 24 boxes `extract-zip@2.0.1` STALLS mid-extract — its
5
+ * promise never resolves AND never rejects. Because the self-heal `await`s it,
6
+ * when the event loop drains Node exits 0 with a partial extract and no
7
+ * electron.exe, so `repairElectron` silently no-ops. extract-zip 2.0.1 is the
8
+ * LATEST published release, so "just bump it" is impossible.
9
+ *
10
+ * robustExtract() survives that with three independent layers, none of which
11
+ * can report a false success:
12
+ * 1. BOUND extract-zip with an idle timer (reset on each onEntry) + a hard max
13
+ * timer, so a stall becomes a catchable outcome — and the live timer keeps
14
+ * the event loop alive so the process can't exit 0 before we fall back.
15
+ * 2. FALL BACK to a native OS unzip (tar / Expand-Archive on Windows,
16
+ * ditto / unzip on macOS, unzip / tar on Linux) — each confirmed to extract
17
+ * the exact electron zip the field box choked on.
18
+ * 3. Only report success when files actually landed on disk. The electron
19
+ * exe-stat verify stays upstream (electron-quarantine.verifyExtractOutcome).
20
+ *
21
+ * Everything network/spawn/timer-facing is dependency-INJECTABLE so tests never
22
+ * hit the real clock, spawn a real process, or extract a real binary.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const path = require('path');
28
+ const fsDefault = require('fs');
29
+ const { spawnSync } = require('child_process');
30
+
31
+ // No-progress window: if extract-zip reports no new entry for this long AND has
32
+ // not settled, treat it as the silent stall. Reset on every onEntry so a slow-
33
+ // but-progressing extract is never falsely aborted.
34
+ const IDLE_MS = 30_000;
35
+ // Hard cap so a "drips one entry forever" pathology can't run unbounded.
36
+ const MAX_MS = 240_000;
37
+
38
+ /** PowerShell single-quoted string literal, injection-safe (double any quote). */
39
+ function psQuote(s) {
40
+ return `'${String(s).replace(/'/g, "''")}'`;
41
+ }
42
+
43
+ /**
44
+ * Native OS unzip strategies, tried in order per platform. Each writes the
45
+ * zip's entries at the ROOT of `dir` — the SAME on-disk layout extract-zip
46
+ * produces (electron.exe, resources/, locales/, ...). Confirmed on the field
47
+ * box (Expand-Archive) and locally (tar/bsdtar + Expand-Archive, both <1s).
48
+ * @returns {Array<{name:string, cmd:string, args:string[]}>}
49
+ */
50
+ function nativeUnzipPlan(zip, dir, platform = process.platform) {
51
+ if (platform === 'win32') {
52
+ // ABSOLUTE path to System32 bsdtar (Win10 1803+/11). A bare "tar" resolves
53
+ // to GNU tar when git-bash/MSYS is on PATH — GNU tar reads "C:\..." as a
54
+ // remote host ("Cannot connect to C:") and can't read zips at all. path.win32
55
+ // keeps this a valid Windows path even when the plan is built off-Windows.
56
+ const winTar = path.win32.join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'tar.exe');
57
+ return [
58
+ // bsdtar — fast, auto-detects zip format. If absent (rare/WOW64), the
59
+ // spawn errors ENOENT and we fall through to Expand-Archive below.
60
+ { name: 'tar', cmd: winTar, args: ['-xf', zip, '-C', dir] },
61
+ // Universal Windows fallback; silence progress so stdio:'ignore' is clean.
62
+ {
63
+ name: 'Expand-Archive',
64
+ cmd: 'powershell',
65
+ args: ['-NoProfile', '-NonInteractive', '-Command',
66
+ `$ProgressPreference='SilentlyContinue'; Expand-Archive -LiteralPath ${psQuote(zip)} -DestinationPath ${psQuote(dir)} -Force`],
67
+ },
68
+ ];
69
+ }
70
+ if (platform === 'darwin') {
71
+ return [
72
+ { name: 'ditto', cmd: 'ditto', args: ['-x', '-k', zip, dir] },
73
+ { name: 'unzip', cmd: 'unzip', args: ['-o', '-q', zip, '-d', dir] },
74
+ ];
75
+ }
76
+ return [
77
+ { name: 'unzip', cmd: 'unzip', args: ['-o', '-q', zip, '-d', dir] },
78
+ { name: 'tar', cmd: 'tar', args: ['-xf', zip, '-C', dir] },
79
+ ];
80
+ }
81
+
82
+ /** True if `dir` exists and holds at least one entry. */
83
+ function dirNonEmpty(fs, dir) {
84
+ try {
85
+ return fs.readdirSync(dir).length > 0;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /** Remove everything inside `dir` (best-effort) so the next strategy starts clean. */
92
+ function cleanDir(fs, dir) {
93
+ try {
94
+ for (const entry of fs.readdirSync(dir)) {
95
+ fs.rmSync(path.join(dir, entry), { recursive: true, force: true });
96
+ }
97
+ } catch {
98
+ /* best-effort */
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Run extract-zip bounded by an idle timer (reset on each onEntry) and a hard
104
+ * max timer. NEVER rejects — resolves {ok:true} on completion, {ok:false,reason}
105
+ * on stall/throw. The timers keep the event loop alive so a stalled extract
106
+ * can't let the process exit 0 before we fall back. NOTE: a stalled extract-zip
107
+ * promise is abandoned (2.0.1 has no cancel API); it holds a fd until the short-
108
+ * lived process exits — acceptable versus a wedged, no-op self-heal.
109
+ */
110
+ function runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, setTimer, clearTimer }) {
111
+ return new Promise((resolve) => {
112
+ let settled = false;
113
+ let idleTimer = null;
114
+ let maxTimer = null;
115
+ const done = (val) => {
116
+ if (settled) { return; }
117
+ settled = true;
118
+ if (idleTimer !== null) { clearTimer(idleTimer); }
119
+ if (maxTimer !== null) { clearTimer(maxTimer); }
120
+ resolve(val);
121
+ };
122
+ const armIdle = () => {
123
+ if (idleTimer !== null) { clearTimer(idleTimer); }
124
+ idleTimer = setTimer(() => done({ ok: false, reason: `stalled: no extract progress for ${idleMs}ms` }), idleMs);
125
+ };
126
+ maxTimer = setTimer(() => done({ ok: false, reason: `stalled: exceeded ${maxMs}ms` }), maxMs);
127
+ armIdle();
128
+ try {
129
+ const result = extractZip(zip, {
130
+ dir,
131
+ onEntry: (entry, zipfile) => {
132
+ armIdle(); // progress → restart the idle window
133
+ if (onEntry) {
134
+ try { onEntry(entry, zipfile); } catch { /* caller onEntry must not break extraction */ }
135
+ }
136
+ },
137
+ });
138
+ Promise.resolve(result).then(
139
+ () => done({ ok: true }),
140
+ (e) => done({ ok: false, reason: (e && e.message) || 'extract-zip threw' }),
141
+ );
142
+ } catch (e) {
143
+ done({ ok: false, reason: (e && e.message) || 'extract-zip threw synchronously' });
144
+ }
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Extract `zip` into `dir`, surviving a stalled or broken extract-zip.
150
+ *
151
+ * CONTRACT: a returned {strategy} means files LANDED in `dir` — NOT that any
152
+ * specific payload (e.g. electron.exe) is present. Callers needing a usable
153
+ * binary MUST still stat it (electron-quarantine.verifyExtractOutcome does).
154
+ *
155
+ * @param {string} zip absolute path to the .zip
156
+ * @param {object} opts
157
+ * @param {string} opts.dir destination dir (created if absent)
158
+ * @param {function} [opts.onEntry] forwarded to extract-zip's onEntry
159
+ * @param {string} [opts.platform] override process.platform (native plan)
160
+ * @param {number} [opts.idleMs] no-progress window before treating as stalled
161
+ * @param {number} [opts.maxMs] hard cap for both extract-zip and each spawn
162
+ * @param {object} [opts.deps] injected { fs, extractZip, spawn, setTimeout, clearTimeout, log }
163
+ * @returns {Promise<{strategy:string, fallback?:boolean, extractZipReason?:string}>}
164
+ * @throws {Error} code 'UNZIP_ALL_FAILED' when no strategy produced files.
165
+ */
166
+ async function robustExtract(zip, opts = {}) {
167
+ const {
168
+ dir,
169
+ onEntry,
170
+ platform = process.platform,
171
+ idleMs = IDLE_MS,
172
+ maxMs = MAX_MS,
173
+ deps = {},
174
+ } = opts;
175
+ const fs = deps.fs || fsDefault;
176
+ const extractZip = deps.extractZip || require('extract-zip');
177
+ const spawn = deps.spawn || spawnSync;
178
+ const setTimer = deps.setTimeout || setTimeout;
179
+ const clearTimer = deps.clearTimeout || clearTimeout;
180
+ const log = deps.log || (() => {});
181
+
182
+ fs.mkdirSync(dir, { recursive: true });
183
+
184
+ // Strategy 1: extract-zip, bounded. Trust it only if it RESOLVED and files landed.
185
+ const z = await runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, setTimer, clearTimer });
186
+ if (z.ok && dirNonEmpty(fs, dir)) {
187
+ return { strategy: 'extract-zip' };
188
+ }
189
+
190
+ // extract-zip stalled / threw / produced nothing → clean partial output, go native.
191
+ const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
192
+ cleanDir(fs, dir);
193
+ log(`[amicus] extract-zip did not complete (${zipReason}); falling back to native unzip.`);
194
+
195
+ const failures = [];
196
+ for (const strat of nativeUnzipPlan(zip, dir, platform)) {
197
+ let res;
198
+ try {
199
+ res = spawn(strat.cmd, strat.args, { stdio: 'ignore', windowsHide: true, timeout: maxMs });
200
+ } catch (e) {
201
+ failures.push(`${strat.name}: spawn ${(e && e.code) || (e && e.message) || 'threw'}`);
202
+ continue;
203
+ }
204
+ // A spawn error OR an external signal-kill (status:null, e.g. SIGKILL/OOM,
205
+ // possibly leaving partial files) is a FAILURE — never trust dirNonEmpty here.
206
+ if (res && (res.error || res.signal)) {
207
+ failures.push(`${strat.name}: ${res.error ? (res.error.code || res.error.message) : `killed by ${res.signal}`}`);
208
+ cleanDir(fs, dir);
209
+ continue;
210
+ }
211
+ if (res && typeof res.status === 'number' && res.status !== 0) {
212
+ failures.push(`${strat.name}: exit ${res.status}`);
213
+ cleanDir(fs, dir);
214
+ continue;
215
+ }
216
+ if (dirNonEmpty(fs, dir)) {
217
+ log(`[amicus] recovered via native unzip (${strat.name}).`);
218
+ return { strategy: strat.name, fallback: true, extractZipReason: zipReason };
219
+ }
220
+ failures.push(`${strat.name}: produced no files`);
221
+ cleanDir(fs, dir);
222
+ }
223
+
224
+ const err = new Error(
225
+ `unzip failed for ${zip} (extract-zip: ${zipReason}; native: ${failures.join('; ') || 'no native strategy available'})`,
226
+ );
227
+ err.code = 'UNZIP_ALL_FAILED';
228
+ throw err;
229
+ }
230
+
231
+ module.exports = { robustExtract, nativeUnzipPlan, IDLE_MS, MAX_MS };