Skip to content
Closed
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}"`);
Comment on lines +56 to +59
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In PowerShell, how should apostrophes be escaped inside single-quoted string literals, and is -LiteralPathpreferred for paths passed toExpand-Archive?

💡 Result:

In PowerShell, apostrophes (single quotes) inside single-quoted string literals are escaped by doubling them: use two consecutive single quotes (''') to represent a literal apostrophe. The backtick () escape character does not work in single-quoted strings, as they are treated literally with no substitutions or escapes except for the doubled single quote. For paths passed to Expand-Archive, -LiteralPath is preferred when the path contains special characters like [ or ] that could be interpreted as wildcards by the -Path parameter. -LiteralPath treats the path exactly as typed, without wildcard expansion. Official examples recommend -LiteralPath for such cases, e.g., Expand-Archive -LiteralPath 'C:\Archives\Draft[v1].zip' -DestinationPath C:\Reference.

Citations:


🏁 Script executed:

find . -name "install.js" -type f | head -20

Repository: supermodeltools/cli

Length of output: 1852


Escape apostrophes in paths before passing to PowerShell.

Paths like C:\Users\O'Brien\Downloads\archive.zip would break the current code. When that path hits the single-quoted string (e.g., '-Path '${archive}'), the apostrophe closes the string early, and PowerShell fails to expand the archive.

The fix is straightforward: double any apostrophes before interpolating (PowerShell treats '' as a literal apostrophe in single-quoted strings). Also switch to -LiteralPath since it's designed exactly for this—it treats the path literally without trying to interpret special characters like brackets as wildcards.

Suggested fix
 function extractZip(archive, tmpDir, execFn) {
   const exec = execFn || execSync;
+  const psEscape = (value) => String(value).replace(/'/g, "''");
   try {
     exec(`tar -xf "${archive}" -C "${tmpDir}"`);
   } catch {
+    const archivePs = psEscape(archive);
+    const tmpDirPs = psEscape(tmpDir);
     const psCommand =
       `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive` +
-      ` -Force -Path '${archive}' -DestinationPath '${tmpDir}'; break }` +
+      ` -Force -LiteralPath '${archivePs}' -DestinationPath '${tmpDirPs}' -ErrorAction Stop; break }` +
       ` catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
     exec(`powershell -NoProfile -Command "${psCommand}"`);
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`$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}"`);
function extractZip(archive, tmpDir, execFn) {
const exec = execFn || execSync;
const psEscape = (value) => String(value).replace(/'/g, "''");
try {
exec(`tar -xf "${archive}" -C "${tmpDir}"`);
} catch {
const archivePs = psEscape(archive);
const tmpDirPs = psEscape(tmpDir);
const psCommand =
`$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive` +
` -Force -LiteralPath '${archivePs}' -DestinationPath '${tmpDirPs}' -ErrorAction Stop; break }` +
` catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
exec(`powershell -NoProfile -Command "${psCommand}"`);
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@npm/install.js` around lines 56 - 59, Escape single quotes in the archive and
tmpDir values before interpolating into the PowerShell command (replace ' with
''), and use the -LiteralPath parameter instead of -Path in the Expand-Archive
call; update the construction of psCommand (the string passed to
exec(`powershell -NoProfile -Command "${psCommand}"`)) to interpolate the
escaped archive and tmpDir and to use -LiteralPath for both
-DestinationPath/-LiteralPath as appropriate so paths containing apostrophes
(e.g., O'Brien) are handled safely.

}
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