diff --git a/npm/install.js b/npm/install.js index 0e900b0..652daec 100644 --- a/npm/install.js +++ b/npm/install.js @@ -1,19 +1,18 @@ #!/usr/bin/env node // postinstall: downloads the correct platform binary from GitHub Releases. +"use strict"; + const { execSync } = require("child_process"); const fs = require("fs"); const https = require("https"); const os = require("os"); const path = require("path"); -const { createGunzip } = require("zlib"); const REPO = "supermodeltools/cli"; const BIN_DIR = path.join(__dirname, "bin"); const BIN_PATH = path.join(BIN_DIR, process.platform === "win32" ? "supermodel.exe" : "supermodel"); -const VERSION = require("./package.json").version; - const PLATFORM_MAP = { darwin: "Darwin", linux: "Linux", @@ -30,21 +29,6 @@ function fail(msg) { process.exit(1); } -const platform = PLATFORM_MAP[process.platform]; -const arch = ARCH_MAP[os.arch()]; - -if (!platform) fail(`Unsupported platform: ${process.platform}`); -if (!arch) fail(`Unsupported architecture: ${os.arch()}`); - -const ext = process.platform === "win32" ? "zip" : "tar.gz"; -const archive = `supermodel_${platform}_${arch}.${ext}`; -const tag = `v${VERSION}`; -const url = `https://github.com/${REPO}/releases/download/${tag}/${archive}`; - -console.log(`[supermodel] Downloading ${archive} from GitHub Releases...`); - -fs.mkdirSync(BIN_DIR, { recursive: true }); - function download(url, dest, cb) { const file = fs.createWriteStream(dest); https.get(url, (res) => { @@ -59,22 +43,55 @@ function download(url, dest, cb) { }).on("error", (err) => fail(err.message)); } -const tmpArchive = path.join(os.tmpdir(), archive); - -download(url, tmpArchive, () => { - if (ext === "tar.gz") { - execSync(`tar -xzf "${tmpArchive}" -C "${BIN_DIR}" supermodel`); - } else { - // Windows: Expand-Archive extracts all files, so extract to a temporary - // directory and copy only the binary. - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-extract-")); - execSync( - `powershell -NoProfile -Command "Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'"`, - ); - fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH); - fs.rmSync(tmpDir, { recursive: true, force: true }); +// extractZip extracts a .zip archive into tmpDir. +// Tries native tar first (Windows 10+); falls back to PowerShell Expand-Archive +// with a retry loop to handle transient Antivirus file locks. +// Accepts an optional execFn for testing (defaults to execSync). +function extractZip(archive, tmpDir, execFn) { + const exec = execFn || execSync; + try { + exec(`tar -xf "${archive}" -C "${tmpDir}"`); + } catch { + const psCommand = + `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive` + + ` -Force -Path '${archive}' -DestinationPath '${tmpDir}'; break }` + + ` catch { Start-Sleep -Seconds 1; $RetryCount++ } }`; + exec(`powershell -NoProfile -Command "${psCommand}"`); } - if (process.platform !== "win32") fs.chmodSync(BIN_PATH, 0o755); - fs.unlinkSync(tmpArchive); - console.log(`[supermodel] Installed to ${BIN_PATH}`); -}); +} + +if (require.main === module) { + const platform = PLATFORM_MAP[process.platform]; + const arch = ARCH_MAP[os.arch()]; + + if (!platform) fail(`Unsupported platform: ${process.platform}`); + if (!arch) fail(`Unsupported architecture: ${os.arch()}`); + + const ext = process.platform === "win32" ? "zip" : "tar.gz"; + const archive = `supermodel_${platform}_${arch}.${ext}`; + const tag = `v${require("./package.json").version}`; + const url = `https://github.com/${REPO}/releases/download/${tag}/${archive}`; + const tmpArchive = path.join(os.tmpdir(), archive); + + console.log(`[supermodel] Downloading ${archive} from GitHub Releases...`); + fs.mkdirSync(BIN_DIR, { recursive: true }); + + download(url, tmpArchive, () => { + if (ext === "tar.gz") { + execSync(`tar -xzf "${tmpArchive}" -C "${BIN_DIR}" supermodel`); + } else { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-extract-")); + try { + extractZip(tmpArchive, tmpDir); + fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } + if (process.platform !== "win32") fs.chmodSync(BIN_PATH, 0o755); + fs.unlinkSync(tmpArchive); + console.log(`[supermodel] Installed to ${BIN_PATH}`); + }); +} + +module.exports = { extractZip }; diff --git a/npm/install.test.js b/npm/install.test.js new file mode 100644 index 0000000..6d29a02 --- /dev/null +++ b/npm/install.test.js @@ -0,0 +1,133 @@ +// Tests for the Windows zip extraction logic in install.js. +// Uses Node's built-in test runner (node:test, available since Node 18). + +"use strict"; + +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execSync } = require("child_process"); +const { extractZip } = require("./install"); + +// createTestZip builds a real .zip containing a single file named "supermodel.exe" +// using the system zip/tar command. Skips on platforms where neither is available. +function createTestZip(t) { + const src = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-test-src-")); + const binary = path.join(src, "supermodel.exe"); + fs.writeFileSync(binary, "fake binary"); + + const archive = path.join(os.tmpdir(), `supermodel-test-${process.pid}.zip`); + try { + // Use system zip or tar to build the archive. + try { + execSync(`zip -j "${archive}" "${binary}"`, { stdio: "pipe" }); + } catch { + execSync(`tar -cf "${archive}" --format=zip -C "${src}" supermodel.exe`, { stdio: "pipe" }); + } + } catch { + fs.rmSync(src, { recursive: true, force: true }); + return null; // zip tooling not available — caller should skip + } + fs.rmSync(src, { recursive: true, force: true }); + return archive; +} + +test("extractZip extracts via tar when tar succeeds", () => { + const archive = createTestZip(); + if (!archive) { + // Skip gracefully if zip tooling unavailable (e.g. minimal CI image). + console.log(" skipped: zip tooling not available"); + return; + } + try { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-test-out-")); + try { + let called = null; + extractZip(archive, tmpDir, (cmd) => { + called = cmd; + // Only simulate tar; let the actual extraction happen via real execSync + // if this is the tar command. + if (cmd.startsWith("tar")) { + execSync(cmd, { stdio: "pipe" }); + } else { + throw new Error("should not reach PowerShell"); + } + }); + assert.ok(called.startsWith("tar"), "should have called tar first"); + const extracted = fs.readdirSync(tmpDir); + assert.ok(extracted.length > 0, "tmpDir should contain extracted files"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } finally { + fs.unlinkSync(archive); + } +}); + +test("extractZip falls back to PowerShell when tar fails", () => { + const commands = []; + // Simulate tar failing; PowerShell "succeeds" (no-op). + extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => { + commands.push(cmd); + if (cmd.startsWith("tar")) throw new Error("tar not available"); + // PowerShell call — just record it, don't execute. + }); + + assert.equal(commands.length, 2, "should have attempted tar then PowerShell"); + assert.ok(commands[0].startsWith("tar"), "first call should be tar"); + assert.ok(commands[1].includes("powershell"), "second call should be PowerShell"); + assert.ok(commands[1].includes("Expand-Archive"), "PowerShell command should use Expand-Archive"); +}); + +test("extractZip PowerShell fallback includes retry loop", () => { + const commands = []; + extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => { + commands.push(cmd); + if (cmd.startsWith("tar")) throw new Error("tar not available"); + }); + + const psCmd = commands.find((c) => c.includes("powershell")); + assert.ok(psCmd, "PowerShell command should be present"); + assert.ok(psCmd.includes("$RetryCount"), "should include retry counter"); + assert.ok(psCmd.includes("Start-Sleep"), "should include sleep between retries"); + assert.ok(psCmd.includes("-lt 10"), "should retry up to 10 times"); +}); + +test("extractZip uses tar when both succeed — tar wins", () => { + const commands = []; + extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => { + commands.push(cmd); + // Both would succeed; tar is tried first and doesn't throw. + }); + + assert.equal(commands.length, 1, "should only call tar when it succeeds"); + assert.ok(commands[0].startsWith("tar"), "the single call should be tar"); +}); + +test("extractZip passes archive and tmpDir paths into tar command", () => { + const archive = "/tmp/test.zip"; + const tmpDir = "/tmp/extract-dir"; + let tarCmd = null; + extractZip(archive, tmpDir, (cmd) => { + tarCmd = cmd; + }); + + assert.ok(tarCmd.includes(archive), "tar command should include archive path"); + assert.ok(tarCmd.includes(tmpDir), "tar command should include tmpDir path"); +}); + +test("extractZip passes archive and tmpDir paths into PowerShell fallback", () => { + const archive = "/tmp/test.zip"; + const tmpDir = "/tmp/extract-dir"; + const commands = []; + extractZip(archive, tmpDir, (cmd) => { + commands.push(cmd); + if (cmd.startsWith("tar")) throw new Error("tar failed"); + }); + + const psCmd = commands[1]; + assert.ok(psCmd.includes(archive), "PowerShell command should include archive path"); + assert.ok(psCmd.includes(tmpDir), "PowerShell command should include tmpDir path"); +}); diff --git a/npm/package.json b/npm/package.json index 6855d26..0734291 100644 --- a/npm/package.json +++ b/npm/package.json @@ -12,7 +12,8 @@ "supermodel": "./bin.js" }, "scripts": { - "postinstall": "node install.js" + "postinstall": "node install.js", + "test": "node --test install.test.js" }, "files": [ "bin.js",