fix(cli): replace extract-zip with fflate to fix silent install on Node v24.16.0+ (#253)

On Node v24.16.0 / v26.1.0+, `impeccable install` printed "Downloading
impeccable skills...", exited 0, and installed nothing. A Node streams
regression (nodejs/node#63487) made pause()/resume() no-ops on destroyed
streams, stalling extract-zip's yauzl/fd-slicer read stack partway through;
its promise never settled and the process exited clean with no error.

Swap extract-zip for fflate across both extraction call sites
(downloadAndExtractBundle, copyOrExtractLocalBundle) via a new extractZip
helper. fflate decompresses from an in-memory buffer and never touches the
fs stream path, so it is immune on every Node version. It is pure JS with
zero dependencies, so the Windows fix from #198 (no `unzip` binary) holds.
Unlike extract-zip, fflate is actively maintained.

Because extractZip writes entries itself, it guards against zip-slip (`../`
entries escaping the target dir). Tests add a many-file regression guard
(fails on partial extraction) and a zip-slip rejection test.

Verified end-to-end: the real 1,194-file universal bundle extracts and
installs completely.

Fixes #250.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-15 20:33:43 -07:00
committed by GitHub
co-authored by Claude
parent fff712ca98
commit c27a75ad41
5 changed files with 152 additions and 30 deletions
+36 -4
View File
@@ -10,13 +10,13 @@
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs';
import { join, resolve, dirname, relative, isAbsolute } from 'node:path';
import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
import { createInterface, emitKeypressEvents } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { get } from 'node:https';
import { createHash } from 'node:crypto';
import { tmpdir, homedir } from 'node:os';
import extract from 'extract-zip';
import { unzipSync } from 'fflate';
import { getHookConsent, setHookConsent } from '../../lib/impeccable-config.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -436,6 +436,37 @@ function hashSkillsDir(skillsDir) {
return entries.join(',');
}
/**
* Extract every entry of a zip archive into `targetDir`.
*
* This replaces `extract-zip`, whose `yauzl`/`fd-slicer` read stack stalls on
* Node v24.16.0 / v26.1.0+ (nodejs/node#63487): `pause()`/`resume()` became
* no-ops on destroyed streams, so extraction stops after a handful of entries,
* its promise never settles, and -- because nothing else keeps the event loop
* alive -- the CLI exits 0 with no error, silently installing nothing.
*
* `fflate` decompresses from an in-memory buffer and never touches the fs
* stream path, so it is immune to that regression on every Node version. It is
* pure JS with zero dependencies, which keeps the Windows fix from #198 intact
* (no `unzip` binary required). We write the entries to disk ourselves, which
* lets us guard against zip-slip (`../` entries escaping `targetDir`).
*/
async function extractZip(zipPath, targetDir) {
const entries = unzipSync(readFileSync(zipPath));
const root = resolve(targetDir);
for (const [entryPath, bytes] of Object.entries(entries)) {
// Directory entries arrive as zero-length names ending in `/`; the files
// beneath them create their parents via mkdirSync below.
if (entryPath.endsWith('/')) continue;
const dest = resolve(root, entryPath);
if (dest !== root && !dest.startsWith(root + sep)) {
throw new Error(`Refusing to extract entry outside target dir: ${entryPath}`);
}
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, bytes);
}
}
/**
* Download the universal bundle to a temp dir and return its path.
* Caller is responsible for cleanup.
@@ -448,7 +479,7 @@ async function downloadAndExtractBundle() {
const tmpDir = join(tmpdir(), `impeccable-update-${Date.now()}`);
await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip);
mkdirSync(tmpDir, { recursive: true });
await extract(tmpZip, { dir: tmpDir });
await extractZip(tmpZip, tmpDir);
rmSync(tmpZip, { force: true });
return tmpDir;
}
@@ -467,7 +498,7 @@ async function copyOrExtractLocalBundle(sourceValue) {
return tmpDir;
}
await extract(source, { dir: tmpDir });
await extractZip(source, tmpDir);
return tmpDir;
}
@@ -1701,6 +1732,7 @@ export {
copyProviderSkills,
decideHookInstall,
expectedHookDests,
extractZip,
formatInstallDetectionLines,
linkProviderSkills,
mergeHookManifests,