amicus 1.7.2 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -0
- package/package.json +1 -1
- package/src/sidecar/electron-install.js +19 -20
- package/src/sidecar/electron-lock.js +95 -0
- package/src/sidecar/unzip.js +231 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
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,33 @@ 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
|
+
|
|
23
|
+
## [1.7.3] - 2026-06-30
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **The Electron self-heal no longer wedges itself.** A repair that was killed or hung mid-run (or a
|
|
27
|
+
pre-1.7.3 build) could leave an orphaned single-flight lockfile, after which *every* subsequent repair
|
|
28
|
+
— including `amicus doctor --fix` and the GUI launch — reported "another electron repair is already in
|
|
29
|
+
progress" and did nothing. The lock now records the holder's PID + timestamp and reclaims an orphaned
|
|
30
|
+
lock (dead holder, older than a 15-minute TTL, or the old empty format), so the GUI can self-heal
|
|
31
|
+
again; a live, recent holder still yields honest contention (no double-extract). The controlled
|
|
32
|
+
download is time-boxed (and the last-resort installer bounded) so a stalled fetch can't recreate the
|
|
33
|
+
stuck lock. **After upgrading, an already-stuck lock clears itself on the next repair.**
|
|
34
|
+
|
|
8
35
|
## [1.7.2] - 2026-06-30
|
|
9
36
|
|
|
10
37
|
The Electron self-heal now tells the truth, heals the cases it can, and clearly explains the ones it can't.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
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",
|
|
@@ -22,11 +22,17 @@
|
|
|
22
22
|
|
|
23
23
|
const fsDefault = require('fs');
|
|
24
24
|
const path = require('path');
|
|
25
|
-
const os = require('os');
|
|
26
25
|
const { spawnSync } = require('child_process');
|
|
27
26
|
|
|
28
27
|
const { resolveCacheRoots } = require('./electron-cache');
|
|
29
28
|
const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
|
|
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() {
|
|
@@ -115,22 +121,6 @@ function cachedZip({ version, platform = process.platform, arch = process.arch,
|
|
|
115
121
|
return null;
|
|
116
122
|
}
|
|
117
123
|
|
|
118
|
-
/**
|
|
119
|
-
* Single-flight lockfile: only one caller may extract/install at a time, so
|
|
120
|
-
* concurrent callers don't double-extract (Windows EBUSY). Throws an
|
|
121
|
-
* EEXIST-coded error when another holder is active.
|
|
122
|
-
*/
|
|
123
|
-
function defaultAcquireLock({ electronDir, fs = fsDefault }) {
|
|
124
|
-
const lockPath = path.join(os.tmpdir(), `amicus-electron-repair-${Buffer.from(electronDir).toString('hex').slice(0, 16)}.lock`);
|
|
125
|
-
const fd = fs.openSync(lockPath, 'wx'); // EEXIST if held
|
|
126
|
-
return {
|
|
127
|
-
release() {
|
|
128
|
-
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
129
|
-
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
124
|
/** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
|
|
135
125
|
function writePathTxt({ electronDir, platform, fs }) {
|
|
136
126
|
fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
|
|
@@ -166,6 +156,10 @@ async function controlledProvision({
|
|
|
166
156
|
platform,
|
|
167
157
|
arch,
|
|
168
158
|
checksums: undefined,
|
|
159
|
+
// Bound the fetch so a stalled/blocked network aborts (got v11 timeouts:
|
|
160
|
+
// socket = inactivity, request = total) instead of hanging the repair —
|
|
161
|
+
// a hung-then-killed download is what orphaned the single-flight lock.
|
|
162
|
+
downloadOptions: { timeout: { socket: 60000, request: 480000 } },
|
|
169
163
|
});
|
|
170
164
|
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
171
165
|
}
|
|
@@ -211,10 +205,15 @@ async function repairElectron({
|
|
|
211
205
|
deps = {},
|
|
212
206
|
} = {}) {
|
|
213
207
|
const fs = deps.fs || fsDefault;
|
|
214
|
-
|
|
215
|
-
const
|
|
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 } }));
|
|
211
|
+
// Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
|
|
212
|
+
// provision that reaches runInstaller without an explicit timeoutMs can't hang
|
|
213
|
+
// the holder — the caller's timeoutMs still wins when provided.
|
|
214
|
+
const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
|
|
216
215
|
const findZip = deps.cachedZip || ((o) => cachedZip(o));
|
|
217
|
-
const acquireLock = deps.acquireLock || ((o) =>
|
|
216
|
+
const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
|
|
218
217
|
const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
|
|
219
218
|
|
|
220
219
|
if (!version) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-aware single-flight lock for the electron self-heal (#53).
|
|
3
|
+
*
|
|
4
|
+
* Only one process may extract/install electron at a time (concurrent extracts
|
|
5
|
+
* trip Windows EBUSY). The lock is a file in the OS temp dir keyed by the
|
|
6
|
+
* electron dir. It records the holder PID + a timestamp so a LATER caller can
|
|
7
|
+
* detect — and STEAL — a lock orphaned by a killed/crashed/rebooted repair (or
|
|
8
|
+
* left by a pre-v1.7.3 build, which wrote an empty lockfile). A bare wx lock
|
|
9
|
+
* with no staleness recovery wedges EVERY future repair forever with "another
|
|
10
|
+
* electron repair is already in progress" once the holder dies without
|
|
11
|
+
* releasing — which is exactly what it did in the field.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fsDefault = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
|
|
20
|
+
/** A real repair (even a ~170MB download) finishes well within this. */
|
|
21
|
+
const STALE_MS = 15 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/** Temp-dir lockfile path, keyed by the electron install dir. */
|
|
24
|
+
function lockPathFor(electronDir) {
|
|
25
|
+
const key = Buffer.from(electronDir).toString('hex').slice(0, 16);
|
|
26
|
+
return path.join(os.tmpdir(), `amicus-electron-repair-${key}.lock`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Is an existing lockfile orphaned? Stale when it predates STALE_MS, its holder
|
|
31
|
+
* process is gone (ESRCH), or it is empty / corrupt / pre-v1.7.3 format. A lock
|
|
32
|
+
* held by a LIVE, recent process is NOT stale (real contention).
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isStaleLock(lockPath, fs = fsDefault, now = Date.now()) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(lockPath, 'utf-8');
|
|
39
|
+
} catch {
|
|
40
|
+
return false; // already gone — absent, not stale
|
|
41
|
+
}
|
|
42
|
+
let meta;
|
|
43
|
+
try {
|
|
44
|
+
meta = JSON.parse(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
return true; // empty / corrupt / pre-v1.7.3 empty lockfile -> orphaned
|
|
47
|
+
}
|
|
48
|
+
if (typeof meta.at === 'number' && now - meta.at > STALE_MS) {
|
|
49
|
+
return true; // older than any real repair
|
|
50
|
+
}
|
|
51
|
+
if (typeof meta.pid === 'number') {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(meta.pid, 0); // throws if the process is gone
|
|
54
|
+
return false; // holder alive
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return !!(err && err.code === 'ESRCH'); // ESRCH => dead/stale; EPERM => alive (not ours)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true; // no usable pid/timestamp -> treat as orphaned
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Acquire the single-flight repair lock. Throws an EEXIST-coded error ONLY when
|
|
64
|
+
* a live, recent process genuinely holds it; otherwise steals an orphaned lock.
|
|
65
|
+
* @returns {{ release: () => void }}
|
|
66
|
+
*/
|
|
67
|
+
function acquireRepairLock({ electronDir, fs = fsDefault }) {
|
|
68
|
+
const lockPath = lockPathFor(electronDir);
|
|
69
|
+
|
|
70
|
+
function create() {
|
|
71
|
+
// Atomic exclusive create WITH content in a single call: the file is never
|
|
72
|
+
// observable empty (no open-then-write gap a concurrent reader could
|
|
73
|
+
// misjudge as a corrupt orphan and steal). 'wx' throws EEXIST if held.
|
|
74
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
create();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (!e || e.code !== 'EEXIST') { throw e; }
|
|
81
|
+
if (!isStaleLock(lockPath, fs)) { throw e; } // genuinely held by a live process
|
|
82
|
+
// Steal the orphan, then re-create. If a real process races us into the
|
|
83
|
+
// gap, the second create() throws EEXIST and we surface honest contention.
|
|
84
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
85
|
+
create();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
release() {
|
|
90
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { acquireRepairLock, isStaleLock, lockPathFor, STALE_MS };
|
|
@@ -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 };
|