From d709d5988397325188fdb174fa1b56586108c119 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:50:39 -0700 Subject: [PATCH] fix(launcher): fall back to bundled Bun when the prebuilt binary cannot start Motivation: On Windows with Application Control enforcing, the unsigned prebuilt hunk.exe cannot be created as a process. The npm launcher printed the raw spawnSync error and exited 1, even though the bundled Bun runtime (which is signed) could run the JavaScript entrypoint. Approach: When spawning the prebuilt binary fails with a spawn error (result.error), print a short notice and continue to the bundled Bun runtime. Non-zero exits and signals from a binary that did start still propagate unchanged, HUNK_BIN_PATH overrides still exit on error, and if no bundled Bun exists the original error is printed as before. This does not sign the executable; that part of the report is not addressed. Validation: ran `bun test test/cli/entrypoint.test.ts -t "bin wrapper"` (3 pass, on macOS arm64). The new test, which replaces the prebuilt binary with a directory so process creation fails and supplies a fake bun, fails without the launcher change and passes with it. I could not run this on Windows or against a real Application Control block. Report: https://github.com/modem-dev/hunk/issues/563 Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Assisted-by: claude-sonnet-5-5 (via Claude Code) --- .changeset/launcher-spawn-fallback.md | 5 +++ packages/hunk/bin/hunk.cjs | 31 +++++++++++++++--- test/cli/entrypoint.test.ts | 45 +++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 .changeset/launcher-spawn-fallback.md 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");