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
+3 -19
View File
@@ -8,7 +8,7 @@
"css-select": "^7.0.0",
"css-tree": "^3.2.1",
"domutils": "^4.0.2",
"extract-zip": "^2.0.1",
"fflate": "^0.8.3",
"htmlparser2": "^12.0.0",
"marked": "^18.0.5",
},
@@ -357,8 +357,6 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
@@ -547,8 +545,6 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
@@ -591,8 +587,6 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
@@ -607,12 +601,12 @@
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="],
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
@@ -645,8 +639,6 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="],
"get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="],
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
@@ -945,8 +937,6 @@
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -973,8 +963,6 @@
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"puppeteer": ["puppeteer@25.1.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.4", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1624250", "lilconfig": "^3.1.3", "puppeteer-core": "25.1.0", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/puppeteer/node/cli.js" } }, "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A=="],
"puppeteer-core": ["puppeteer-core@25.1.0", "", { "dependencies": { "@puppeteer/browsers": "3.0.4", "chromium-bidi": "16.0.1", "devtools-protocol": "0.0.1624250", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.0" } }, "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ=="],
@@ -1207,8 +1195,6 @@
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
@@ -1261,8 +1247,6 @@
"yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+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,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "impeccable",
"version": "3.0.1",
"version": "3.0.2",
"author": "Paul Bakaus",
"description": "Design skills, commands, and anti-pattern detection for AI coding agents",
"keywords": [
@@ -81,7 +81,7 @@
"css-select": "^7.0.0",
"css-tree": "^3.2.1",
"domutils": "^4.0.2",
"extract-zip": "^2.0.1",
"fflate": "^0.8.3",
"htmlparser2": "^12.0.0",
"marked": "^18.0.5"
},
+7
View File
@@ -84,6 +84,13 @@ import '../styles/changelog-faq-kinpaku.css';
</ul>
</article>
<article id="cli-v3.0.2" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.2</span><span class="cf-date">June 16, 2026</span></header>
<ul class="cf-items">
<li><strong>Install no longer fails silently on newer Node.</strong> On Node v24.16.0 and v26.1.0+, <code>impeccable install</code> printed "Downloading impeccable skills...", exited 0, and wrote nothing. A Node streams change (<a href="https://github.com/nodejs/node/issues/63487" target="_blank" rel="noopener">nodejs/node#63487</a>) made <code>pause()</code>/<code>resume()</code> no-ops on destroyed streams, which stalled the old <code>extract-zip</code> unpacker partway through and left its promise unsettled. ZIP extraction now runs on <code>fflate</code>, which decompresses in memory and is unaffected on every Node version, while staying pure JS so the Windows install fix holds. Reported by <a href="https://github.com/kenryu42" target="_blank" rel="noopener">@kenryu42</a> in <a href="https://github.com/pbakaus/impeccable/issues/250" target="_blank" rel="noopener">#250</a>.</li>
</ul>
</article>
<article id="cli-v3.0.1" class="cf-entry">
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.1</span><span class="cf-date">June 15, 2026</span></header>
<ul class="cf-items">
+104 -5
View File
@@ -7,17 +7,21 @@
* 0-byte universal.zip and every `npx impeccable install` failed with
* "End-of-central-directory signature not found". Nothing covered the zip
* writer, so the suite stayed green. These tests exercise the real writer and
* round-trip through extract-zip (the same unpacker the CLI uses).
* round-trip through the same unpacker the CLI uses (extractZip, backed by
* fflate). The many-file extraction test additionally guards the Node v24.16.0
* / v26.1.0+ silent partial-extraction regression (nodejs/node#63487) that
* made `npx impeccable install` exit 0 after writing only a fraction of the
* bundle.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync, readFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync, readFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import extract from 'extract-zip';
import { createProviderZip, createAllZips } from '../scripts/lib/zip.js';
import { extractZip } from '../cli/bin/commands/skills.mjs';
function makeUniversalTree(distDir) {
const skillDir = join(distDir, 'universal', 'skills', 'impeccable');
@@ -27,6 +31,54 @@ function makeUniversalTree(distDir) {
writeFileSync(join(distDir, 'universal', '.claude', 'settings.json'), '{}\n');
}
/**
* Build a representative multi-provider universal tree carrying far more
* entries than the Node v24.16.0 extract-zip stall point (~31 files). The
* partial-extraction regression test relies on this so a regression in the
* unpacker fails the count assertion instead of shipping silently. Returns the
* number of files written.
*/
function makeLargeUniversalTree(distDir, { providers = ['.claude', '.cursor', '.agents', '.gemini', '.github', '.kiro'], scriptCount = 8, extraSkills = ['audit', 'polish'] } = {}) {
let files = 0;
for (const provider of providers) {
// The impeccable skill ships a scripts/ dir with many files, like the real
// bundle. This is where most entries live.
const scriptsDir = join(distDir, 'universal', provider, 'skills', 'impeccable', 'scripts');
mkdirSync(scriptsDir, { recursive: true });
writeFileSync(join(distDir, 'universal', provider, 'skills', 'impeccable', 'SKILL.md'), `---\nname: impeccable\n---\nprovider ${provider}\n`);
files += 1;
for (let i = 0; i < scriptCount; i++) {
writeFileSync(join(scriptsDir, `script-${i}.mjs`), `// ${provider} script ${i}\nexport default ${i};\n`);
files += 1;
}
// Sibling skills with a SKILL.md + a reference file each.
for (const name of extraSkills) {
const refDir = join(distDir, 'universal', provider, 'skills', name, 'reference');
mkdirSync(refDir, { recursive: true });
writeFileSync(join(distDir, 'universal', provider, 'skills', name, 'SKILL.md'), `---\nname: ${name}\n---\n`);
writeFileSync(join(refDir, `${name}.md`), `# ${name} reference\n`);
files += 2;
}
// Provider root config (mirrors .claude/settings.json, .cursor/hooks.json).
writeFileSync(join(distDir, 'universal', provider, 'config.json'), '{}\n');
files += 1;
}
return files;
}
/** Count regular files under a directory tree (used to assert full extraction). */
function countFiles(dir) {
let count = 0;
const walk = (d) => {
for (const entry of readdirSync(d, { withFileTypes: true })) {
if (entry.isDirectory()) walk(join(d, entry.name));
else count += 1;
}
};
walk(dir);
return count;
}
describe('release bundle zip writer', () => {
it('createAllZips produces a non-empty universal.zip that unpacks to the skill tree', async () => {
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-'));
@@ -38,9 +90,10 @@ describe('release bundle zip writer', () => {
assert.ok(existsSync(zipPath), 'universal.zip was not created');
assert.ok(statSync(zipPath).size > 0, 'universal.zip is empty (0 bytes)');
// Round-trip: the CLI downloads this exact artifact and extract()s it.
// Round-trip through the same unpacker the CLI uses (extractZip). The CLI
// downloads this exact artifact and extractZip()s it.
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-'));
await extract(zipPath, { dir: out });
await extractZip(zipPath, out);
const skillMd = join(out, 'skills', 'impeccable', 'SKILL.md');
assert.ok(existsSync(skillMd), 'unpacked bundle is missing skills/impeccable/SKILL.md');
assert.match(readFileSync(skillMd, 'utf8'), /name: impeccable/);
@@ -49,6 +102,52 @@ describe('release bundle zip writer', () => {
rmSync(out, { recursive: true, force: true });
});
it('REGRESSION: extracts every entry of a many-file bundle (no silent partial extraction on Node v24.16.0+)', async () => {
// Guards nodejs/node#63487: extract-zip's yauzl/fd-slicer read stack stalls
// on Node v24.16.0 / v26.1.0+ (pause/resume on a destroyed stream became a
// no-op), so extraction stops early, its promise never settles, and --
// because nothing else keeps the event loop alive -- `npx impeccable install`
// exits 0 with no error, silently installing a fraction of the bundle. This
// fixture carries 84 files (well past the ~31 entry stall point), so a
// unpacker that stops early fails the count assertion here instead of
// shipping silently. fflate decompresses in-memory and is immune.
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-large-'));
const written = makeLargeUniversalTree(dist);
await createAllZips(dist);
const zipPath = join(dist, 'universal.zip');
assert.ok(existsSync(zipPath), 'universal.zip was not created');
assert.ok(statSync(zipPath).size > 0, 'universal.zip is empty (0 bytes)');
// Round-trip through the exact code path downloadAndExtractBundle runs.
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-large-'));
await extractZip(zipPath, out);
const extracted = countFiles(out);
assert.equal(extracted, written, `partial extraction: only ${extracted} of ${written} files unpacked`);
rmSync(dist, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});
it('rejects a zip entry whose path escapes the target dir (zip-slip)', async () => {
// extractZip writes entries itself, so it must refuse `../` traversal that a
// malicious or malformed archive could use to land files outside targetDir.
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-slip-'));
const { zipSync, strToU8 } = await import('fflate');
const zipped = zipSync({ '../escaped.txt': strToU8('pwned\n') });
const zipPath = join(dist, 'evil.zip');
writeFileSync(zipPath, zipped);
const out = mkdtempSync(join(tmpdir(), 'imp-unzip-slip-'));
await assert.rejects(() => extractZip(zipPath, out), /outside target dir/);
assert.ok(!existsSync(join(dist, 'escaped.txt')), 'zip-slip entry escaped the target dir');
rmSync(dist, { recursive: true, force: true });
rmSync(out, { recursive: true, force: true });
});
it('createProviderZip throws when the source has no files (no silent 0-byte artifact)', async () => {
const dist = mkdtempSync(join(tmpdir(), 'imp-zip-empty-'));
mkdirSync(join(dist, 'universal'), { recursive: true });