Skip to content

fix(windows): fix Expand-Archive Windows Defender lock bug via native tar#131

Closed
PunchingElm wants to merge 2 commits intosupermodeltools:mainfrom
PunchingElm:fix/windows-npm-install-lock
Closed

fix(windows): fix Expand-Archive Windows Defender lock bug via native tar#131
PunchingElm wants to merge 2 commits intosupermodeltools:mainfrom
PunchingElm:fix/windows-npm-install-lock

Conversation

@PunchingElm
Copy link
Copy Markdown
Contributor

@PunchingElm PunchingElm commented Apr 15, 2026

Replaced PowerShell Expand-Archive with native Windows tar to prevent ENOENT crashes caused by Windows Defender locking the downloaded archive during npm installation.

Summary by CodeRabbit

  • New Features

    • Installer now includes fallback extraction mechanism with automatic retry logic to improve reliability across different system configurations.
  • Bug Fixes

    • Enhanced error handling and resource cleanup during the installation process.
  • Tests

    • Added comprehensive test coverage for the installer's extraction functionality.

@PunchingElm PunchingElm requested a review from greynewell as a code owner April 15, 2026 12:45
Copilot AI review requested due to automatic review settings April 15, 2026 12:45
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates the npm postinstall flow on Windows to reduce install failures caused by antivirus file locks by preferring native tar extraction over PowerShell Expand-Archive.

Changes:

  • Use Windows-native tar to extract the downloaded .zip into a temp directory.
  • Fall back to Expand-Archive with a retry loop if tar extraction fails.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread npm/install.js Outdated
Comment on lines 71 to 79
try {
execSync(`tar -xf "${tmpArchive}" -C "${tmpDir}"`);
} catch {
const psCommand = `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'; break } catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
execSync(
`powershell -NoProfile -Command "${psCommand}"`,
);
}
fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH);
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

The PowerShell retry loop swallows failures: if Expand-Archive keeps failing for all 10 retries, the script exits successfully and the next line throws a less-informative ENOENT on copyFileSync. Consider making the PowerShell command fail/throw after the final retry (non-zero exit code) and/or explicitly verify that the expected extracted file exists before proceeding so the user gets a clear installation error.

Suggested change
try {
execSync(`tar -xf "${tmpArchive}" -C "${tmpDir}"`);
} catch {
const psCommand = `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'; break } catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
execSync(
`powershell -NoProfile -Command "${psCommand}"`,
);
}
fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH);
const extractedBinary = path.join(tmpDir, "supermodel.exe");
try {
execSync(`tar -xf "${tmpArchive}" -C "${tmpDir}"`);
} catch {
const psCommand = `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'; return } catch { Start-Sleep -Seconds 1; $RetryCount++ } }; throw 'Failed to extract archive with Expand-Archive after 10 attempts.'`;
execSync(
`powershell -NoProfile -Command "${psCommand}"`,
);
}
if (!fs.existsSync(extractedBinary)) {
fail(`Extraction failed: expected executable not found at ${extractedBinary}`);
}
fs.copyFileSync(extractedBinary, BIN_PATH);

Copilot uses AI. Check for mistakes.
Comment thread npm/install.js Outdated
Comment on lines 72 to 80
execSync(`tar -xf "${tmpArchive}" -C "${tmpDir}"`);
} catch {
const psCommand = `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'; break } catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
execSync(
`powershell -NoProfile -Command "${psCommand}"`,
);
}
fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH);
fs.rmSync(tmpDir, { recursive: true, force: true });
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

If tar/Expand-Archive succeeds but copyFileSync throws (e.g., expected file not present), tmpDir won’t be removed because cleanup happens after the copy. Consider restructuring this block with try/finally so tmpDir is always removed, even when an error occurs.

Suggested change
execSync(`tar -xf "${tmpArchive}" -C "${tmpDir}"`);
} catch {
const psCommand = `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'; break } catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
execSync(
`powershell -NoProfile -Command "${psCommand}"`,
);
}
fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH);
fs.rmSync(tmpDir, { recursive: true, force: true });
try {
execSync(`tar -xf "${tmpArchive}" -C "${tmpDir}"`);
} catch {
const psCommand = `$RetryCount = 0; while ($RetryCount -lt 10) { try { Expand-Archive -Force -Path '${tmpArchive}' -DestinationPath '${tmpDir}'; break } catch { Start-Sleep -Seconds 1; $RetryCount++ } }`;
execSync(
`powershell -NoProfile -Command "${psCommand}"`,
);
}
fs.copyFileSync(path.join(tmpDir, "supermodel.exe"), BIN_PATH);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}

Copilot uses AI. Check for mistakes.
…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>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 15, 2026

Walkthrough

The installer script was refactored to export a testable extractZip function that handles Windows ZIP extraction with tar fallback and PowerShell retry logic. Tests were added to validate the extraction behavior. A test script was added to package.json.

Changes

Cohort / File(s) Summary
Installer Refactoring
npm/install.js
Restructured as a conditional module with strict mode; moved core logic under require.main === module gate; extracted ZIP handling into exported extractZip(archive, tmpDir, execFn) function with tar-first strategy and PowerShell fallback including retry loop; improved cleanup with try/finally pattern.
Test Coverage
npm/install.test.js
Added comprehensive test suite using Node's built-in test runner; validates extractZip control flow including tar attempt, PowerShell fallback, retry logic verification, and command path assertions; gracefully skips real extraction if system tools unavailable.
NPM Configuration
npm/package.json
Added test script entry to run install.test.js with Node's test runner.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A bundle seeks escape from zips so tight,
First tar attempts to set it free—
When that falters, PowerShell takes flight,
With retries counting steadily,
Each test ensures the path is right! 📦✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is incomplete. While it briefly describes the change, it lacks the structured format with 'What', 'Why', and 'Test plan' sections required by the repository template. Expand the description to include the three required template sections: What (change details), Why (motivation/context with issue links), and Test plan (with checkboxes for make test, make lint, and manual testing notes).
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing a Windows Defender file-lock issue in npm installation by using native tar instead of PowerShell Expand-Archive.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
npm/install.js (1)

79-93: Also cleanup tmpArchive in a finally block.

If extraction/copy/chmod throws, the downloaded archive currently remains in temp storage.

Suggested cleanup flow
   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}`);
+    try {
+      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);
+      console.log(`[supermodel] Installed to ${BIN_PATH}`);
+    } finally {
+      fs.rmSync(tmpArchive, { force: true });
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@npm/install.js` around lines 79 - 93, Wrap the post-download cleanup and
install steps for download(...) in a try/finally so the tmpArchive is always
removed: move extraction (extractZip), copy (fs.copyFileSync), chmod
(fs.chmodSync) and tmpDir cleanup (fs.rmSync) into a try where applicable and
call fs.unlinkSync(tmpArchive) in the finally block; ensure the tar.gz branch
still cleans up tmpArchive and that BIN_DIR/BIN_PATH handling and
console.log(`[supermodel] Installed to ${BIN_PATH}`) remain after successful
install.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@npm/install.js`:
- Around line 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.

---

Nitpick comments:
In `@npm/install.js`:
- Around line 79-93: Wrap the post-download cleanup and install steps for
download(...) in a try/finally so the tmpArchive is always removed: move
extraction (extractZip), copy (fs.copyFileSync), chmod (fs.chmodSync) and tmpDir
cleanup (fs.rmSync) into a try where applicable and call
fs.unlinkSync(tmpArchive) in the finally block; ensure the tar.gz branch still
cleans up tmpArchive and that BIN_DIR/BIN_PATH handling and
console.log(`[supermodel] Installed to ${BIN_PATH}`) remain after successful
install.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b1706888-9c6c-4060-a05c-e382fa2e70e1

📥 Commits

Reviewing files that changed from the base of the PR and between 4e8b37d and c56837c.

📒 Files selected for processing (3)
  • npm/install.js
  • npm/install.test.js
  • npm/package.json

Comment thread npm/install.js
Comment on lines +56 to +59
`$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}"`);
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants