Skip to content

Commit 1d019f8

Browse files
authored
fix(npm): restore execute bits for prebuilt binaries (#204)
1 parent aac5076 commit 1d019f8

File tree

5 files changed

+150
-34
lines changed

5 files changed

+150
-34
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ All notable user-visible changes to Hunk are documented in this file.
1414

1515
### Fixed
1616

17+
- Restored execute permissions for packaged prebuilt binaries so `npm install -g hunkdiff` works on root-owned installs without `spawnSync … EACCES` failures.
18+
1719
## [0.9.3] - 2026-04-13
1820

1921
### Fixed

scripts/prebuilt-package-helpers.test.ts

Lines changed: 20 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,25 @@ 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.name).toBe("hunkdiff-linux-x64");
94+
expect(manifest.version).toBe("1.2.3");
95+
expect(manifest.bin).toEqual({
96+
hunk: "./bin/hunk",
97+
});
98+
expect(manifest.os).toEqual(["linux"]);
99+
expect(manifest.cpu).toEqual(["x64"]);
100+
});
101+
82102
test("sortPlatformPackageSpecs keeps package publish order stable", () => {
83103
const reversed = [...PLATFORM_PACKAGE_MATRIX].reverse();
84104
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: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
#!/usr/bin/env bun
22

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

720
function run(command: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
821
const proc = Bun.spawnSync(command, {
@@ -32,40 +45,91 @@ const releaseRoot = releaseNpmDir(repoRoot);
3245
const hostSpec = getHostPlatformPackageSpec();
3346
const tempRoot = path.join(repoRoot, "tmp");
3447
mkdirSync(tempRoot, { recursive: true });
35-
const packageDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-pack-"));
36-
const installDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-install-"));
37-
const nodeBinary = Bun.spawnSync(["bash", "-lc", "command -v node"], {
38-
stdin: "ignore",
39-
stdout: "pipe",
40-
stderr: "pipe",
41-
env: process.env,
42-
});
43-
const resolvedNode = Buffer.from(nodeBinary.stdout).toString("utf8").trim();
44-
if (nodeBinary.exitCode !== 0 || resolvedNode.length === 0) {
45-
throw new Error("Could not resolve node on PATH for the prebuilt install smoke test.");
46-
}
47-
const nodeDir = path.dirname(resolvedNode);
48+
let packageDir: string | undefined;
49+
let installDir: string | undefined;
50+
let smokeMetaDir: string | undefined;
4851

4952
try {
50-
run(["npm", "pack", "--pack-destination", packageDir], {
51-
cwd: path.join(releaseRoot, hostSpec.packageName),
53+
packageDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-pack-"));
54+
installDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-install-"));
55+
smokeMetaDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-meta-"));
56+
57+
const nodeBinary = Bun.spawnSync(["bash", "-lc", "command -v node"], {
58+
stdin: "ignore",
59+
stdout: "pipe",
60+
stderr: "pipe",
61+
env: process.env,
5262
});
63+
const resolvedNode = Buffer.from(nodeBinary.stdout).toString("utf8").trim();
64+
if (nodeBinary.exitCode !== 0 || resolvedNode.length === 0) {
65+
throw new Error("Could not resolve node on PATH for the prebuilt install smoke test.");
66+
}
67+
const bashBinary = Bun.spawnSync(["bash", "-lc", "command -v bash"], {
68+
stdin: "ignore",
69+
stdout: "pipe",
70+
stderr: "pipe",
71+
env: process.env,
72+
});
73+
const resolvedBash = Buffer.from(bashBinary.stdout).toString("utf8").trim();
74+
if (bashBinary.exitCode !== 0 || resolvedBash.length === 0) {
75+
throw new Error("Could not resolve bash on PATH for the prebuilt install smoke test.");
76+
}
77+
const nodeDir = path.dirname(resolvedNode);
78+
const bashDir = path.dirname(resolvedBash);
79+
5380
run(["npm", "pack", "--pack-destination", packageDir], {
54-
cwd: path.join(releaseRoot, "hunkdiff"),
81+
cwd: path.join(releaseRoot, hostSpec.packageName),
5582
});
5683

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

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

63-
const sanitizedPath = `${path.join(installDir, "bin")}:${nodeDir}`;
107+
const sanitizedPath = [path.join(installDir, "bin"), nodeDir, bashDir].join(":");
64108
const installedHunk = path.join(installDir, "bin", "hunk");
109+
const installedPlatformBinary = path.join(
110+
installDir,
111+
"lib",
112+
"node_modules",
113+
"hunkdiff",
114+
"node_modules",
115+
hostSpec.packageName,
116+
"bin",
117+
binaryFilenameForSpec(hostSpec),
118+
);
65119
const commandEnv = {
66120
...process.env,
67121
PATH: sanitizedPath,
68122
};
123+
124+
if (process.platform !== "win32") {
125+
const installedBinaryMode = statSync(installedPlatformBinary).mode & 0o777;
126+
if ((installedBinaryMode & 0o111) === 0) {
127+
throw new Error(
128+
`Expected installed platform binary to keep execute bits, got mode ${installedBinaryMode.toString(8)} at ${installedPlatformBinary}`,
129+
);
130+
}
131+
}
132+
69133
const help = run([installedHunk, "--help"], {
70134
env: commandEnv,
71135
});
@@ -112,6 +176,13 @@ try {
112176

113177
console.log(`Verified prebuilt npm install smoke test with ${hostSpec.packageName}`);
114178
} finally {
115-
rmSync(packageDir, { recursive: true, force: true });
116-
rmSync(installDir, { recursive: true, force: true });
179+
if (packageDir) {
180+
rmSync(packageDir, { recursive: true, force: true });
181+
}
182+
if (installDir) {
183+
rmSync(installDir, { recursive: true, force: true });
184+
}
185+
if (smokeMetaDir) {
186+
rmSync(smokeMetaDir, { recursive: true, force: true });
187+
}
117188
}

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,
@@ -122,18 +123,7 @@ function stagePlatformPackage(
122123
chmodSync(stagedBinary, 0o755);
123124
cpSync(path.join(repoRoot, "LICENSE"), path.join(packageDir, "LICENSE"));
124125

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

139129
function collectArtifactSpecs(artifactRoot: string) {

0 commit comments

Comments
 (0)