Skip to content

Commit 86b3df0

Browse files
greynewellclaude
andcommitted
fix(npm): refactor extractZip for testability, add tests, fix tmpDir cleanup
- Extract extractZip() as a testable function with injectable execFn - Move side-effectful module-level code inside require.main guard so require('./install') no longer triggers a download - Add try/finally to guarantee tmpDir cleanup even if copyFileSync throws - Add 6 node:test tests covering tar path, PowerShell fallback, retry loop structure, and path interpolation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ab4bad3 commit 86b3df0

File tree

3 files changed

+187
-41
lines changed

3 files changed

+187
-41
lines changed

npm/install.js

Lines changed: 52 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
#!/usr/bin/env node
22
// postinstall: downloads the correct platform binary from GitHub Releases.
33

4+
"use strict";
5+
46
const { execSync } = require("child_process");
57
const fs = require("fs");
68
const https = require("https");
79
const os = require("os");
810
const path = require("path");
9-
const { createGunzip } = require("zlib");
1011

1112
const REPO = "supermodeltools/cli";
1213
const BIN_DIR = path.join(__dirname, "bin");
1314
const BIN_PATH = path.join(BIN_DIR, process.platform === "win32" ? "supermodel.exe" : "supermodel");
1415

15-
const VERSION = require("./package.json").version;
16-
1716
const PLATFORM_MAP = {
1817
darwin: "Darwin",
1918
linux: "Linux",
@@ -30,21 +29,6 @@ function fail(msg) {
3029
process.exit(1);
3130
}
3231

33-
const platform = PLATFORM_MAP[process.platform];
34-
const arch = ARCH_MAP[os.arch()];
35-
36-
if (!platform) fail(`Unsupported platform: ${process.platform}`);
37-
if (!arch) fail(`Unsupported architecture: ${os.arch()}`);
38-
39-
const ext = process.platform === "win32" ? "zip" : "tar.gz";
40-
const archive = `supermodel_${platform}_${arch}.${ext}`;
41-
const tag = `v${VERSION}`;
42-
const url = `https://github.com/${REPO}/releases/download/${tag}/${archive}`;
43-
44-
console.log(`[supermodel] Downloading ${archive} from GitHub Releases...`);
45-
46-
fs.mkdirSync(BIN_DIR, { recursive: true });
47-
4832
function download(url, dest, cb) {
4933
const file = fs.createWriteStream(dest);
5034
https.get(url, (res) => {
@@ -59,27 +43,55 @@ function download(url, dest, cb) {
5943
}).on("error", (err) => fail(err.message));
6044
}
6145

62-
const tmpArchive = path.join(os.tmpdir(), archive);
46+
// extractZip extracts a .zip archive into tmpDir.
47+
// Tries native tar first (Windows 10+); falls back to PowerShell Expand-Archive
48+
// with a retry loop to handle transient Antivirus file locks.
49+
// Accepts an optional execFn for testing (defaults to execSync).
50+
function extractZip(archive, tmpDir, execFn) {
51+
const exec = execFn || execSync;
52+
try {
53+
exec(`tar -xf "${archive}" -C "${tmpDir}"`);
54+
} catch {
55+
const psCommand =
56+
`$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive` +
57+
` -Force -Path '${archive}' -DestinationPath '${tmpDir}'; break }` +
58+
` catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
59+
exec(`powershell -NoProfile -Command "${psCommand}"`);
60+
}
61+
}
62+
63+
if (require.main === module) {
64+
const platform = PLATFORM_MAP[process.platform];
65+
const arch = ARCH_MAP[os.arch()];
66+
67+
if (!platform) fail(`Unsupported platform: ${process.platform}`);
68+
if (!arch) fail(`Unsupported architecture: ${os.arch()}`);
69+
70+
const ext = process.platform === "win32" ? "zip" : "tar.gz";
71+
const archive = `supermodel_${platform}_${arch}.${ext}`;
72+
const tag = `v${require("./package.json").version}`;
73+
const url = `https://github.com/${REPO}/releases/download/${tag}/${archive}`;
74+
const tmpArchive = path.join(os.tmpdir(), archive);
6375

64-
download(url, tmpArchive, () => {
65-
if (ext === "tar.gz") {
66-
execSync(`tar -xzf "${tmpArchive}" -C "${BIN_DIR}" supermodel`);
67-
} else {
68-
// Windows 10+ natively supports tar. Using tar avoids Antivirus file lock
69-
// crashes commonly seen with PowerShell's Expand-Archive cmdlet.
70-
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-extract-"));
71-
try {
72-
execSync(`tar -xf "${tmpArchive}" -C "${tmpDir}"`);
73-
} catch {
74-
const psCommand = `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'; break } catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
75-
execSync(
76-
`powershell -NoProfile -Command "${psCommand}"`,
77-
);
76+
console.log(`[supermodel] Downloading ${archive} from GitHub Releases...`);
77+
fs.mkdirSync(BIN_DIR, { recursive: true });
78+
79+
download(url, tmpArchive, () => {
80+
if (ext === "tar.gz") {
81+
execSync(`tar -xzf "${tmpArchive}" -C "${BIN_DIR}" supermodel`);
82+
} else {
83+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-extract-"));
84+
try {
85+
extractZip(tmpArchive, tmpDir);
86+
fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH);
87+
} finally {
88+
fs.rmSync(tmpDir, { recursive: true, force: true });
89+
}
7890
}
79-
fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH);
80-
fs.rmSync(tmpDir, { recursive: true, force: true });
81-
}
82-
if (process.platform !== "win32") fs.chmodSync(BIN_PATH, 0o755);
83-
fs.unlinkSync(tmpArchive);
84-
console.log(`[supermodel] Installed to ${BIN_PATH}`);
85-
});
91+
if (process.platform !== "win32") fs.chmodSync(BIN_PATH, 0o755);
92+
fs.unlinkSync(tmpArchive);
93+
console.log(`[supermodel] Installed to ${BIN_PATH}`);
94+
});
95+
}
96+
97+
module.exports = { extractZip };

npm/install.test.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Tests for the Windows zip extraction logic in install.js.
2+
// Uses Node's built-in test runner (node:test, available since Node 18).
3+
4+
"use strict";
5+
6+
const { test } = require("node:test");
7+
const assert = require("node:assert/strict");
8+
const fs = require("fs");
9+
const os = require("os");
10+
const path = require("path");
11+
const { execSync } = require("child_process");
12+
const { extractZip } = require("./install");
13+
14+
// createTestZip builds a real .zip containing a single file named "supermodel.exe"
15+
// using the system zip/tar command. Skips on platforms where neither is available.
16+
function createTestZip(t) {
17+
const src = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-test-src-"));
18+
const binary = path.join(src, "supermodel.exe");
19+
fs.writeFileSync(binary, "fake binary");
20+
21+
const archive = path.join(os.tmpdir(), `supermodel-test-${process.pid}.zip`);
22+
try {
23+
// Use system zip or tar to build the archive.
24+
try {
25+
execSync(`zip -j "${archive}" "${binary}"`, { stdio: "pipe" });
26+
} catch {
27+
execSync(`tar -cf "${archive}" --format=zip -C "${src}" supermodel.exe`, { stdio: "pipe" });
28+
}
29+
} catch {
30+
fs.rmSync(src, { recursive: true, force: true });
31+
return null; // zip tooling not available — caller should skip
32+
}
33+
fs.rmSync(src, { recursive: true, force: true });
34+
return archive;
35+
}
36+
37+
test("extractZip extracts via tar when tar succeeds", () => {
38+
const archive = createTestZip();
39+
if (!archive) {
40+
// Skip gracefully if zip tooling unavailable (e.g. minimal CI image).
41+
console.log(" skipped: zip tooling not available");
42+
return;
43+
}
44+
try {
45+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "supermodel-test-out-"));
46+
try {
47+
let called = null;
48+
extractZip(archive, tmpDir, (cmd) => {
49+
called = cmd;
50+
// Only simulate tar; let the actual extraction happen via real execSync
51+
// if this is the tar command.
52+
if (cmd.startsWith("tar")) {
53+
execSync(cmd, { stdio: "pipe" });
54+
} else {
55+
throw new Error("should not reach PowerShell");
56+
}
57+
});
58+
assert.ok(called.startsWith("tar"), "should have called tar first");
59+
const extracted = fs.readdirSync(tmpDir);
60+
assert.ok(extracted.length > 0, "tmpDir should contain extracted files");
61+
} finally {
62+
fs.rmSync(tmpDir, { recursive: true, force: true });
63+
}
64+
} finally {
65+
fs.unlinkSync(archive);
66+
}
67+
});
68+
69+
test("extractZip falls back to PowerShell when tar fails", () => {
70+
const commands = [];
71+
// Simulate tar failing; PowerShell "succeeds" (no-op).
72+
extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => {
73+
commands.push(cmd);
74+
if (cmd.startsWith("tar")) throw new Error("tar not available");
75+
// PowerShell call — just record it, don't execute.
76+
});
77+
78+
assert.equal(commands.length, 2, "should have attempted tar then PowerShell");
79+
assert.ok(commands[0].startsWith("tar"), "first call should be tar");
80+
assert.ok(commands[1].includes("powershell"), "second call should be PowerShell");
81+
assert.ok(commands[1].includes("Expand-Archive"), "PowerShell command should use Expand-Archive");
82+
});
83+
84+
test("extractZip PowerShell fallback includes retry loop", () => {
85+
const commands = [];
86+
extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => {
87+
commands.push(cmd);
88+
if (cmd.startsWith("tar")) throw new Error("tar not available");
89+
});
90+
91+
const psCmd = commands.find((c) => c.includes("powershell"));
92+
assert.ok(psCmd, "PowerShell command should be present");
93+
assert.ok(psCmd.includes("$RetryCount"), "should include retry counter");
94+
assert.ok(psCmd.includes("Start-Sleep"), "should include sleep between retries");
95+
assert.ok(psCmd.includes("-lt 10"), "should retry up to 10 times");
96+
});
97+
98+
test("extractZip uses tar when both succeed — tar wins", () => {
99+
const commands = [];
100+
extractZip("/fake/archive.zip", "/fake/tmpdir", (cmd) => {
101+
commands.push(cmd);
102+
// Both would succeed; tar is tried first and doesn't throw.
103+
});
104+
105+
assert.equal(commands.length, 1, "should only call tar when it succeeds");
106+
assert.ok(commands[0].startsWith("tar"), "the single call should be tar");
107+
});
108+
109+
test("extractZip passes archive and tmpDir paths into tar command", () => {
110+
const archive = "/tmp/test.zip";
111+
const tmpDir = "/tmp/extract-dir";
112+
let tarCmd = null;
113+
extractZip(archive, tmpDir, (cmd) => {
114+
tarCmd = cmd;
115+
});
116+
117+
assert.ok(tarCmd.includes(archive), "tar command should include archive path");
118+
assert.ok(tarCmd.includes(tmpDir), "tar command should include tmpDir path");
119+
});
120+
121+
test("extractZip passes archive and tmpDir paths into PowerShell fallback", () => {
122+
const archive = "/tmp/test.zip";
123+
const tmpDir = "/tmp/extract-dir";
124+
const commands = [];
125+
extractZip(archive, tmpDir, (cmd) => {
126+
commands.push(cmd);
127+
if (cmd.startsWith("tar")) throw new Error("tar failed");
128+
});
129+
130+
const psCmd = commands[1];
131+
assert.ok(psCmd.includes(archive), "PowerShell command should include archive path");
132+
assert.ok(psCmd.includes(tmpDir), "PowerShell command should include tmpDir path");
133+
});

npm/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"supermodel": "./bin.js"
1313
},
1414
"scripts": {
15-
"postinstall": "node install.js"
15+
"postinstall": "node install.js",
16+
"test": "node --test install.test.js"
1617
},
1718
"files": [
1819
"bin.js",

0 commit comments

Comments
 (0)