diff --git a/.changeset/launcher-spawn-fallback.md b/.changeset/launcher-spawn-fallback.md new file mode 100644 index 000000000..5ef4061ef --- /dev/null +++ b/.changeset/launcher-spawn-fallback.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Fall back to the bundled Bun runtime when the npm launcher cannot start the prebuilt binary, such as when Windows Application Control blocks it. diff --git a/packages/hunk/bin/hunk.cjs b/packages/hunk/bin/hunk.cjs index 19e943592..fa9865335 100755 --- a/packages/hunk/bin/hunk.cjs +++ b/packages/hunk/bin/hunk.cjs @@ -25,7 +25,12 @@ function ensureExecutable(target) { } } -function run(target, args) { +/** + * Runs a target and exits with its status. When `onSpawnError` is given, a failure to create the + * process (for example Windows Application Control blocking an unsigned executable) calls it + * instead of exiting so the caller can try another runtime. + */ +function run(target, args, onSpawnError) { ensureExecutable(target); const result = childProcess.spawnSync(target, args, { stdio: "inherit", @@ -33,6 +38,11 @@ function run(target, args) { }); if (result.error) { + if (onSpawnError) { + onSpawnError(result.error); + return; + } + console.error(result.error.message); process.exit(1); } @@ -121,14 +131,25 @@ if (overrideBinary) { const scriptDir = path.dirname(fs.realpathSync(__filename)); const prebuiltBinary = findInstalledBinary(scriptDir); +const bunBinary = bundledBunRuntime(); +const bunEntrypoint = path.join(__dirname, "..", "dist", "npm", "main.js"); + if (prebuiltBinary) { - run(prebuiltBinary, forwardedArgs); + run(prebuiltBinary, forwardedArgs, (error) => { + // Without a bundled Bun runtime there is nothing to fall back to, so surface the real error. + if (!bunBinary) { + console.error(error.message); + process.exit(1); + } + + console.error( + `hunk: could not start the prebuilt binary (${error.message}); falling back to the bundled Bun runtime.`, + ); + }); } -const bunBinary = bundledBunRuntime(); if (bunBinary) { - const entrypoint = path.join(__dirname, "..", "dist", "npm", "main.js"); - run(bunBinary, [entrypoint, ...forwardedArgs]); + run(bunBinary, [bunEntrypoint, ...forwardedArgs]); } const printablePackages = hostCandidates() diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts index b047284e4..3c134a25c 100644 --- a/test/cli/entrypoint.test.ts +++ b/test/cli/entrypoint.test.ts @@ -243,6 +243,51 @@ describe("CLI entrypoint contracts", () => { }); }); + test("bin wrapper falls back to the bundled Bun runtime when the prebuilt binary cannot start", () => { + if (process.platform === "win32") { + return; + } + + const platform = { darwin: "darwin", linux: "linux" }[process.platform as string]; + const arch = { x64: "x64", arm64: "arm64" }[process.arch as string]; + if (!platform || !arch) { + return; + } + + const tempDir = mkdtempSync(join(tmpdir(), "hunk-wrapper-spawn-fallback-")); + const pkgDir = join(tempDir, "node_modules", "hunkdiff"); + const tempWrapperPath = join(pkgDir, "bin", "hunk.cjs"); + const fakeBunPath = join(pkgDir, "node_modules", "bun", "bin", "bun.exe"); + + try { + mkdirSync(join(pkgDir, "bin"), { recursive: true }); + copyFileSync(join(process.cwd(), "packages", "hunk", "bin", "hunk.cjs"), tempWrapperPath); + + // A directory in place of the binary makes process creation fail the way a blocked exe does. + mkdirSync(join(pkgDir, "node_modules", `hunkdiff-${platform}-${arch}`, "bin", "hunk"), { + recursive: true, + }); + mkdirSync(join(pkgDir, "node_modules", "bun", "bin"), { recursive: true }); + writeFileSync(join(pkgDir, "node_modules", "bun", "package.json"), '{"name":"bun"}'); + writeFileSync(fakeBunPath, '#!/bin/sh\necho "bun-fallback $@"\n', { mode: 0o755 }); + + const proc = Bun.spawnSync(["node", tempWrapperPath, "--version"], { + cwd: tempDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + expect(proc.exitCode).toBe(0); + expect(Buffer.from(proc.stdout).toString("utf8")).toContain("bun-fallback"); + expect(Buffer.from(proc.stdout).toString("utf8")).toContain("--version"); + expect(Buffer.from(proc.stderr).toString("utf8")).toContain("falling back"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("bin wrapper fails clearly when the bundled skill is missing", () => { const tempDir = mkdtempSync(join(tmpdir(), "hunk-wrapper-skill-missing-")); const tempBinDir = join(tempDir, "bin");