Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/launcher-spawn-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 26 additions & 5 deletions packages/hunk/bin/hunk.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,24 @@ 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",
env: process.env,
});

if (result.error) {
if (onSpawnError) {
onSpawnError(result.error);
return;
}

console.error(result.error.message);
process.exit(1);
}
Expand Down Expand Up @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions test/cli/entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down