Skip to content

Commit 2ed14f7

Browse files
committed
fix(npm): restore execute bits for prebuilt binaries
1 parent 5f05a09 commit 2ed14f7

6 files changed

Lines changed: 126 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
### Fixed
1010

11+
## [0.9.4-beta.0] - 2026-04-14
12+
13+
### Fixed
14+
15+
- Restore execute permissions for packaged prebuilt binaries so `npm install -g hunkdiff` no longer fails with `spawnSync … EACCES` on root-owned installs.
16+
1117
## [0.9.3] - 2026-04-13
1218

1319
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hunkdiff",
3-
"version": "0.9.3",
3+
"version": "0.9.4-beta.0",
44
"description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.",
55
"keywords": [
66
"ai",

scripts/prebuilt-package-helpers.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
PLATFORM_PACKAGE_MATRIX,
44
binaryFilenameForSpec,
55
buildOptionalDependencyMap,
6+
buildPlatformPackageManifest,
67
getHostPlatformPackageSpec,
78
getPlatformPackageSpecByName,
89
getPlatformPackageSpecForHost,
@@ -79,6 +80,23 @@ describe("prebuilt package helpers", () => {
7980
);
8081
});
8182

83+
test("buildPlatformPackageManifest declares the native binary as a bin entry", () => {
84+
const manifest = buildPlatformPackageManifest(
85+
{
86+
version: "1.2.3",
87+
description: "Desktop diff viewer",
88+
license: "MIT",
89+
},
90+
getPlatformPackageSpecForHost("linux", "x64"),
91+
);
92+
93+
expect(manifest.bin).toEqual({
94+
hunk: "./bin/hunk",
95+
});
96+
expect(manifest.os).toEqual(["linux"]);
97+
expect(manifest.cpu).toEqual(["x64"]);
98+
});
99+
82100
test("sortPlatformPackageSpecs keeps package publish order stable", () => {
83101
const reversed = [...PLATFORM_PACKAGE_MATRIX].reverse();
84102
expect(sortPlatformPackageSpecs(reversed).map((spec) => spec.packageName)).toEqual([

scripts/prebuilt-package-helpers.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ export function binaryFilenameForSpec(spec: PlatformPackageSpec) {
117117
return spec.os === "windows" ? `${spec.binaryName}.exe` : spec.binaryName;
118118
}
119119

120+
/**
121+
* Build the published manifest for one prebuilt platform package.
122+
*
123+
* Declaring the native binary in `bin` makes npm restore execute bits on install,
124+
* including root-owned global installs where the JS wrapper cannot chmod later.
125+
*/
126+
export function buildPlatformPackageManifest(
127+
rootPackage: {
128+
version: string;
129+
description?: string;
130+
license?: string;
131+
},
132+
spec: PlatformPackageSpec,
133+
) {
134+
const binaryName = binaryFilenameForSpec(spec);
135+
136+
return {
137+
name: spec.packageName,
138+
version: rootPackage.version,
139+
description: `${rootPackage.description} (${spec.os} ${spec.cpu} binary)`,
140+
os: [spec.os === "windows" ? "win32" : spec.os],
141+
cpu: [spec.cpu],
142+
bin: {
143+
hunk: `./bin/${binaryName}`,
144+
},
145+
files: ["bin", "LICENSE"],
146+
license: rootPackage.license,
147+
publishConfig: {
148+
access: "public",
149+
},
150+
};
151+
}
152+
120153
/** Resolve a path under the generated prebuilt npm release directory. */
121154
export function releaseNpmDir(repoRoot: string) {
122155
return path.join(repoRoot, "dist", "release", "npm");

scripts/smoke-prebuilt-install.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
#!/usr/bin/env bun
22

3-
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
3+
import {
4+
cpSync,
5+
mkdtempSync,
6+
mkdirSync,
7+
readFileSync,
8+
rmSync,
9+
statSync,
10+
writeFileSync,
11+
} from "node:fs";
412
import path from "node:path";
5-
import { getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers";
13+
import {
14+
binaryFilenameForSpec,
15+
getHostPlatformPackageSpec,
16+
releaseNpmDir,
17+
} from "./prebuilt-package-helpers";
618

719
function run(command: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
820
const proc = Bun.spawnSync(command, {
@@ -34,6 +46,7 @@ const tempRoot = path.join(repoRoot, "tmp");
3446
mkdirSync(tempRoot, { recursive: true });
3547
const packageDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-pack-"));
3648
const installDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-install-"));
49+
const smokeMetaDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-meta-"));
3750
const nodeBinary = Bun.spawnSync(["bash", "-lc", "command -v node"], {
3851
stdin: "ignore",
3952
stdout: "pipe",
@@ -44,28 +57,73 @@ const resolvedNode = Buffer.from(nodeBinary.stdout).toString("utf8").trim();
4457
if (nodeBinary.exitCode !== 0 || resolvedNode.length === 0) {
4558
throw new Error("Could not resolve node on PATH for the prebuilt install smoke test.");
4659
}
60+
const bashBinary = Bun.spawnSync(["bash", "-lc", "command -v bash"], {
61+
stdin: "ignore",
62+
stdout: "pipe",
63+
stderr: "pipe",
64+
env: process.env,
65+
});
66+
const resolvedBash = Buffer.from(bashBinary.stdout).toString("utf8").trim();
67+
if (bashBinary.exitCode !== 0 || resolvedBash.length === 0) {
68+
throw new Error("Could not resolve bash on PATH for the prebuilt install smoke test.");
69+
}
4770
const nodeDir = path.dirname(resolvedNode);
71+
const bashDir = path.dirname(resolvedBash);
4872

4973
try {
5074
run(["npm", "pack", "--pack-destination", packageDir], {
5175
cwd: path.join(releaseRoot, hostSpec.packageName),
5276
});
53-
run(["npm", "pack", "--pack-destination", packageDir], {
54-
cwd: path.join(releaseRoot, "hunkdiff"),
55-
});
5677

5778
const platformTarball = path.join(packageDir, `${hostSpec.packageName}-${packageVersion}.tgz`);
79+
80+
// Point a temp copy of the staged meta package at the local platform tarball.
81+
// The real manifest uses semver ranges, but this smoke test runs before publish.
82+
const smokePackageDir = path.join(smokeMetaDir, "hunkdiff");
83+
cpSync(path.join(releaseRoot, "hunkdiff"), smokePackageDir, { recursive: true });
84+
const smokeManifestPath = path.join(smokePackageDir, "package.json");
85+
const smokeManifest = JSON.parse(readFileSync(smokeManifestPath, "utf8")) as {
86+
optionalDependencies?: Record<string, string>;
87+
};
88+
smokeManifest.optionalDependencies = {
89+
...smokeManifest.optionalDependencies,
90+
[hostSpec.packageName]: `file:${platformTarball}`,
91+
};
92+
writeFileSync(smokeManifestPath, `${JSON.stringify(smokeManifest, null, 2)}\n`);
93+
94+
run(["npm", "pack", "--pack-destination", packageDir], {
95+
cwd: smokePackageDir,
96+
});
5897
const metaTarball = path.join(packageDir, `hunkdiff-${packageVersion}.tgz`);
5998

60-
run(["npm", "install", "-g", "--prefix", installDir, platformTarball]);
6199
run(["npm", "install", "-g", "--prefix", installDir, metaTarball]);
62100

63-
const sanitizedPath = `${path.join(installDir, "bin")}:${nodeDir}`;
101+
const sanitizedPath = [path.join(installDir, "bin"), nodeDir, bashDir].join(":");
64102
const installedHunk = path.join(installDir, "bin", "hunk");
103+
const installedPlatformBinary = path.join(
104+
installDir,
105+
"lib",
106+
"node_modules",
107+
"hunkdiff",
108+
"node_modules",
109+
hostSpec.packageName,
110+
"bin",
111+
binaryFilenameForSpec(hostSpec),
112+
);
65113
const commandEnv = {
66114
...process.env,
67115
PATH: sanitizedPath,
68116
};
117+
118+
if (process.platform !== "win32") {
119+
const installedBinaryMode = statSync(installedPlatformBinary).mode & 0o777;
120+
if ((installedBinaryMode & 0o111) === 0) {
121+
throw new Error(
122+
`Expected installed platform binary to keep execute bits, got mode ${installedBinaryMode.toString(8)} at ${installedPlatformBinary}`,
123+
);
124+
}
125+
}
126+
69127
const help = run([installedHunk, "--help"], {
70128
env: commandEnv,
71129
});
@@ -102,4 +160,5 @@ try {
102160
} finally {
103161
rmSync(packageDir, { recursive: true, force: true });
104162
rmSync(installDir, { recursive: true, force: true });
163+
rmSync(smokeMetaDir, { recursive: true, force: true });
105164
}

scripts/stage-prebuilt-npm.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import path from "node:path";
1414
import {
1515
binaryFilenameForSpec,
1616
buildOptionalDependencyMap,
17+
buildPlatformPackageManifest,
1718
getHostPlatformPackageSpec,
1819
getPlatformPackageSpecByName,
1920
releaseNpmDir,
@@ -121,18 +122,7 @@ function stagePlatformPackage(
121122
chmodSync(stagedBinary, 0o755);
122123
cpSync(path.join(repoRoot, "LICENSE"), path.join(packageDir, "LICENSE"));
123124

124-
writeJson(path.join(packageDir, "package.json"), {
125-
name: spec.packageName,
126-
version: rootPackage.version,
127-
description: `${rootPackage.description} (${spec.os} ${spec.cpu} binary)`,
128-
os: [spec.os === "windows" ? "win32" : spec.os],
129-
cpu: [spec.cpu],
130-
files: ["bin", "LICENSE"],
131-
license: rootPackage.license,
132-
publishConfig: {
133-
access: "public",
134-
},
135-
});
125+
writeJson(path.join(packageDir, "package.json"), buildPlatformPackageManifest(rootPackage, spec));
136126
}
137127

138128
function collectArtifactSpecs(artifactRoot: string) {

0 commit comments

Comments
 (0)