diff --git a/scripts/stage-whisper-runtime.test.ts b/scripts/stage-whisper-runtime.test.ts index 1227a4a5f3..78a69dbde1 100644 --- a/scripts/stage-whisper-runtime.test.ts +++ b/scripts/stage-whisper-runtime.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { assertPinnedWhisperServerSource, + resolveArchiveExtractionPlan, resolvePrebuiltArtifact, runtimeExecutableName, WHISPER_CPP_COMMIT, @@ -45,4 +46,46 @@ describe("stage-whisper-runtime", () => { expect(runtimeExecutableName("linux")).toBe("whisper-server"); expect(runtimeExecutableName("win")).toBe("whisper-server.exe"); }); + + it("uses Windows-safe extraction for local tarballs and zip prebuilts", () => { + const sourcePlan = resolveArchiveExtractionPlan( + String.raw`C:\\runner\\whisper.cpp.tar.gz`, + String.raw`C:\\runner\\source`, + "win32", + ); + expect(sourcePlan.command).toBe("tar"); + expect(sourcePlan.args).toEqual([ + "--force-local", + "-xzf", + String.raw`C:\\runner\\whisper.cpp.tar.gz`, + "-C", + String.raw`C:\\runner\\source`, + ]); + + const prebuiltPlan = resolveArchiveExtractionPlan( + String.raw`C:\\runner\\whisper-bin-x64.zip`, + String.raw`C:\\runner\\prebuilt`, + "win32", + ); + expect(prebuiltPlan.command).toBe("powershell.exe"); + expect(prebuiltPlan.args).toContain( + "Expand-Archive -LiteralPath $env:SCIENT_WHISPER_ARCHIVE -DestinationPath $env:SCIENT_WHISPER_DESTINATION -Force", + ); + expect(prebuiltPlan.env).toEqual({ + SCIENT_WHISPER_ARCHIVE: String.raw`C:\\runner\\whisper-bin-x64.zip`, + SCIENT_WHISPER_DESTINATION: String.raw`C:\\runner\\prebuilt`, + }); + }); + + it("keeps POSIX archive extraction on tar", () => { + const plan = resolveArchiveExtractionPlan( + "/tmp/whisper-bin-x64.zip", + "/tmp/prebuilt", + "darwin", + ); + expect(plan).toEqual({ + args: ["-xf", "/tmp/whisper-bin-x64.zip", "-C", "/tmp/prebuilt"], + command: "tar", + }); + }); }); diff --git a/scripts/stage-whisper-runtime.ts b/scripts/stage-whisper-runtime.ts index 057bf565c6..44e70aa859 100644 --- a/scripts/stage-whisper-runtime.ts +++ b/scripts/stage-whisper-runtime.ts @@ -58,10 +58,16 @@ interface StageOptions { readonly verbose: boolean; } -function run(command: string, args: readonly string[], options: { readonly cwd?: string } = {}) { +interface RunOptions { + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; +} + +function run(command: string, args: readonly string[], options: RunOptions = {}) { return new Promise((resolvePromise, reject) => { const child = NodeChildProcess.spawn(command, [...args], { ...(options.cwd ? { cwd: options.cwd } : {}), + ...(options.env ? { env: options.env } : {}), shell: false, stdio: "inherit", }); @@ -145,8 +151,75 @@ export function runtimeExecutableName(platform: WhisperRuntimePlatform): string return platform === "win" ? "whisper-server.exe" : "whisper-server"; } +type ArchiveFormat = "tar.gz" | "zip"; +type ArchiveHostPlatform = "win32" | "darwin" | "linux"; + +interface ArchiveExtractionPlan { + readonly args: readonly string[]; + readonly command: string; + readonly env?: Readonly>; +} + +function archiveFormat(fileName: string): ArchiveFormat { + if (fileName.endsWith(".tar.gz")) return "tar.gz"; + if (fileName.endsWith(".zip")) return "zip"; + throw new Error(`Unsupported whisper runtime archive: ${fileName}`); +} + +export function resolveArchiveExtractionPlan( + archive: string, + destination: string, + hostPlatform: ArchiveHostPlatform, +): ArchiveExtractionPlan { + const format = archiveFormat(archive); + if (format === "zip" && hostPlatform === "win32") { + return { + args: [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Expand-Archive -LiteralPath $env:SCIENT_WHISPER_ARCHIVE -DestinationPath $env:SCIENT_WHISPER_DESTINATION -Force", + ], + command: "powershell.exe", + env: { + SCIENT_WHISPER_ARCHIVE: archive, + SCIENT_WHISPER_DESTINATION: destination, + }, + }; + } + + return { + args: [ + ...(hostPlatform === "win32" ? ["--force-local"] : []), + ...(format === "tar.gz" ? ["-xzf"] : ["-xf"]), + archive, + "-C", + destination, + ], + command: "tar", + }; +} + +async function extractArchive( + archive: string, + destination: string, + hostPlatform: ArchiveHostPlatform = process.platform === "win32" + ? "win32" + : process.platform === "darwin" + ? "darwin" + : "linux", +): Promise { + const plan = resolveArchiveExtractionPlan(archive, destination, hostPlatform); + if (plan.env) { + await run(plan.command, plan.args, { env: { ...process.env, ...plan.env } }); + return; + } + await run(plan.command, plan.args); +} + async function extractSource(archive: string, workspace: string): Promise { - await run("tar", ["-xzf", archive, "-C", workspace]); + await extractArchive(archive, workspace); const sourceDirectory = NodePath.join(workspace, `whisper.cpp-${WHISPER_CPP_COMMIT}`); const serverSource = await NodeFSP.readFile( NodePath.join(sourceDirectory, "examples/server/server.cpp"), @@ -205,7 +278,7 @@ async function stagePrebuiltRuntime( ): Promise { const extracted = NodePath.join(workspace, "prebuilt"); await NodeFSP.mkdir(extracted); - await run("tar", ["-xf", archive, "-C", extracted]); + await extractArchive(archive, extracted); if (platform === "win") { const releaseDirectory = NodePath.join(extracted, "Release"); for (const file of await NodeFSP.readdir(releaseDirectory)) {