Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 53 additions & 36 deletions npm/install.js
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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) => {
Expand All @@ -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 };
133 changes: 133 additions & 0 deletions npm/install.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
3 changes: 2 additions & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading