From c27a75ad417d06cd2859e63342082425b53f2481 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 15 Jun 2026 20:33:43 -0700 Subject: [PATCH] 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 --- bun.lock | 22 +------- cli/bin/commands/skills.mjs | 40 +++++++++++-- package.json | 4 +- site/pages/changelog.astro | 7 +++ tests/zip.test.mjs | 109 ++++++++++++++++++++++++++++++++++-- 5 files changed, 152 insertions(+), 30 deletions(-) diff --git a/bun.lock b/bun.lock index 8ba6b8fab..45acd5a58 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 0c9c22f62..7bb1558f9 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -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, diff --git a/package.json b/package.json index f78d48c6a..3b260d324 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/site/pages/changelog.astro b/site/pages/changelog.astro index eb6b66cff..845ec37dc 100644 --- a/site/pages/changelog.astro +++ b/site/pages/changelog.astro @@ -84,6 +84,13 @@ import '../styles/changelog-faq-kinpaku.css'; +
+
CLI v3.0.2June 16, 2026
+
    +
  • Install no longer fails silently on newer Node. On Node v24.16.0 and v26.1.0+, impeccable install printed "Downloading impeccable skills...", exited 0, and wrote nothing. A Node streams change (nodejs/node#63487) made pause()/resume() no-ops on destroyed streams, which stalled the old extract-zip unpacker partway through and left its promise unsettled. ZIP extraction now runs on fflate, which decompresses in memory and is unaffected on every Node version, while staying pure JS so the Windows install fix holds. Reported by @kenryu42 in #250.
  • +
+
+
CLI v3.0.1June 15, 2026
    diff --git a/tests/zip.test.mjs b/tests/zip.test.mjs index 2d7d4342b..d33ba428c 100644 --- a/tests/zip.test.mjs +++ b/tests/zip.test.mjs @@ -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 });