diff --git a/src/hooks/comment-checker/downloader.ts b/src/hooks/comment-checker/downloader.ts index c260c4e48..d57443329 100644 --- a/src/hooks/comment-checker/downloader.ts +++ b/src/hooks/comment-checker/downloader.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs import { join } from "path" import { homedir, tmpdir } from "os" import { createRequire } from "module" +import { extractZip } from "../../shared" const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1" const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log") @@ -95,29 +96,7 @@ async function extractTarGz(archivePath: string, destDir: string): Promise } } -/** - * Extract zip archive using system commands. - */ -async function extractZip(archivePath: string, destDir: string): Promise { - debugLog("Extracting zip:", archivePath, "to", destDir) - - const proc = process.platform === "win32" - ? spawn(["powershell", "-command", `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`], { - stdout: "pipe", - stderr: "pipe", - }) - : spawn(["unzip", "-o", archivePath, "-d", destDir], { - stdout: "pipe", - stderr: "pipe", - }) - - const exitCode = await proc.exited - - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`) - } -} + /** * Download the comment-checker binary from GitHub Releases. diff --git a/src/shared/index.ts b/src/shared/index.ts index d3502dfc9..bb3601ed6 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -20,3 +20,4 @@ export * from "./opencode-config-dir" export * from "./opencode-version" export * from "./permission-compat" export * from "./external-plugin-detector" +export * from "./zip-extractor" diff --git a/src/shared/zip-extractor.ts b/src/shared/zip-extractor.ts new file mode 100644 index 000000000..ee961722f --- /dev/null +++ b/src/shared/zip-extractor.ts @@ -0,0 +1,83 @@ +import { spawn, spawnSync } from "bun" +import { release } from "os" + +const WINDOWS_BUILD_WITH_TAR = 17134 + +function getWindowsBuildNumber(): number | null { + if (process.platform !== "win32") return null + + const parts = release().split(".") + if (parts.length >= 3) { + const build = parseInt(parts[2], 10) + if (!isNaN(build)) return build + } + return null +} + +function isPwshAvailable(): boolean { + if (process.platform !== "win32") return false + const result = spawnSync(["where", "pwsh"], { stdout: "pipe", stderr: "pipe" }) + return result.exitCode === 0 +} + +function escapePowerShellPath(path: string): string { + return path.replace(/'/g, "''") +} + +type WindowsZipExtractor = "tar" | "pwsh" | "powershell" + +function getWindowsZipExtractor(): WindowsZipExtractor { + const buildNumber = getWindowsBuildNumber() + + if (buildNumber !== null && buildNumber >= WINDOWS_BUILD_WITH_TAR) { + return "tar" + } + + if (isPwshAvailable()) { + return "pwsh" + } + + return "powershell" +} + +export async function extractZip(archivePath: string, destDir: string): Promise { + let proc + + if (process.platform === "win32") { + const extractor = getWindowsZipExtractor() + + switch (extractor) { + case "tar": + proc = spawn(["tar", "-xf", archivePath, "-C", destDir], { + stdout: "ignore", + stderr: "pipe", + }) + break + case "pwsh": + proc = spawn(["pwsh", "-Command", `Expand-Archive -Path '${escapePowerShellPath(archivePath)}' -DestinationPath '${escapePowerShellPath(destDir)}' -Force`], { + stdout: "ignore", + stderr: "pipe", + }) + break + case "powershell": + default: + proc = spawn(["powershell", "-Command", `Expand-Archive -Path '${escapePowerShellPath(archivePath)}' -DestinationPath '${escapePowerShellPath(destDir)}' -Force`], { + stdout: "ignore", + stderr: "pipe", + }) + break + } + } else { + proc = spawn(["unzip", "-o", archivePath, "-d", destDir], { + stdout: "ignore", + stderr: "pipe", + }) + } + + const exitCode = await proc.exited + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text() + throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`) + } +} diff --git a/src/tools/ast-grep/downloader.ts b/src/tools/ast-grep/downloader.ts index dfad78fcc..6ed228847 100644 --- a/src/tools/ast-grep/downloader.ts +++ b/src/tools/ast-grep/downloader.ts @@ -1,8 +1,8 @@ -import { spawn } from "bun" import { existsSync, mkdirSync, chmodSync, unlinkSync } from "fs" import { join } from "path" import { homedir } from "os" import { createRequire } from "module" +import { extractZip } from "../../shared" const REPO = "ast-grep/ast-grep" @@ -56,30 +56,7 @@ export function getCachedBinaryPath(): string | null { return existsSync(binaryPath) ? binaryPath : null } -async function extractZip(archivePath: string, destDir: string): Promise { - const proc = - process.platform === "win32" - ? spawn( - [ - "powershell", - "-command", - `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`, - ], - { stdout: "pipe", stderr: "pipe" } - ) - : spawn(["unzip", "-o", archivePath, "-d", destDir], { stdout: "pipe", stderr: "pipe" }) - const exitCode = await proc.exited - - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - const toolHint = - process.platform === "win32" - ? "Ensure PowerShell is available on your system." - : "Please install 'unzip' (e.g., apt install unzip, brew install unzip)." - throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}\n\n${toolHint}`) - } -} export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise { const platformKey = `${process.platform}-${process.arch}` diff --git a/src/tools/grep/downloader.ts b/src/tools/grep/downloader.ts index 612da90a4..350739c89 100644 --- a/src/tools/grep/downloader.ts +++ b/src/tools/grep/downloader.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, chmodSync, unlinkSync, readdirSync } from "node:fs" import { join } from "node:path" import { spawn } from "bun" +import { extractZip as extractZipBase } from "../../shared" export function findFileRecursive(dir: string, filename: string): string | null { try { @@ -74,51 +75,17 @@ async function extractTarGz(archivePath: string, destDir: string): Promise } } -async function extractZipWindows(archivePath: string, destDir: string): Promise { - const proc = spawn( - ["powershell", "-Command", `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`], - { stdout: "pipe", stderr: "pipe" } - ) - const exitCode = await proc.exited - if (exitCode !== 0) { - throw new Error("Failed to extract zip with PowerShell") - } - - const foundPath = findFileRecursive(destDir, "rg.exe") - if (foundPath) { - const destPath = join(destDir, "rg.exe") - if (foundPath !== destPath) { - const { renameSync } = await import("node:fs") - renameSync(foundPath, destPath) - } - } -} - -async function extractZipUnix(archivePath: string, destDir: string): Promise { - const proc = spawn(["unzip", "-o", archivePath, "-d", destDir], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - if (exitCode !== 0) { - throw new Error("Failed to extract zip") - } - - const foundPath = findFileRecursive(destDir, "rg") - if (foundPath) { - const destPath = join(destDir, "rg") - if (foundPath !== destPath) { - const { renameSync } = await import("node:fs") - renameSync(foundPath, destPath) - } - } -} - async function extractZip(archivePath: string, destDir: string): Promise { - if (process.platform === "win32") { - await extractZipWindows(archivePath, destDir) - } else { - await extractZipUnix(archivePath, destDir) + await extractZipBase(archivePath, destDir) + + const binaryName = process.platform === "win32" ? "rg.exe" : "rg" + const foundPath = findFileRecursive(destDir, binaryName) + if (foundPath) { + const destPath = join(destDir, binaryName) + if (foundPath !== destPath) { + const { renameSync } = await import("node:fs") + renameSync(foundPath, destPath) + } } }