Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ All notable user-visible changes to Hunk are documented in this file.

### Fixed

- Restored execute permissions for packaged prebuilt binaries so `npm install -g hunkdiff` works on root-owned installs without `spawnSync … EACCES` failures.

## [0.9.3] - 2026-04-13

### Fixed
Expand Down
20 changes: 20 additions & 0 deletions scripts/prebuilt-package-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
PLATFORM_PACKAGE_MATRIX,
binaryFilenameForSpec,
buildOptionalDependencyMap,
buildPlatformPackageManifest,
getHostPlatformPackageSpec,
getPlatformPackageSpecByName,
getPlatformPackageSpecForHost,
Expand Down Expand Up @@ -79,6 +80,25 @@ describe("prebuilt package helpers", () => {
);
});

test("buildPlatformPackageManifest declares the native binary as a bin entry", () => {
const manifest = buildPlatformPackageManifest(
{
version: "1.2.3",
description: "Desktop diff viewer",
license: "MIT",
},
getPlatformPackageSpecForHost("linux", "x64"),
);

expect(manifest.name).toBe("hunkdiff-linux-x64");
expect(manifest.version).toBe("1.2.3");
expect(manifest.bin).toEqual({
hunk: "./bin/hunk",
});
expect(manifest.os).toEqual(["linux"]);
expect(manifest.cpu).toEqual(["x64"]);
});
Comment thread
benvinegar marked this conversation as resolved.

test("sortPlatformPackageSpecs keeps package publish order stable", () => {
const reversed = [...PLATFORM_PACKAGE_MATRIX].reverse();
expect(sortPlatformPackageSpecs(reversed).map((spec) => spec.packageName)).toEqual([
Expand Down
33 changes: 33 additions & 0 deletions scripts/prebuilt-package-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,39 @@ export function binaryFilenameForSpec(spec: PlatformPackageSpec) {
return spec.os === "windows" ? `${spec.binaryName}.exe` : spec.binaryName;
}

/**
* Build the published manifest for one prebuilt platform package.
*
* Declaring the native binary in `bin` makes npm restore execute bits on install,
* including root-owned global installs where the JS wrapper cannot chmod later.
*/
export function buildPlatformPackageManifest(
rootPackage: {
version: string;
description?: string;
license?: string;
},
spec: PlatformPackageSpec,
) {
const binaryName = binaryFilenameForSpec(spec);

return {
name: spec.packageName,
version: rootPackage.version,
description: `${rootPackage.description} (${spec.os} ${spec.cpu} binary)`,
os: [spec.os === "windows" ? "win32" : spec.os],
cpu: [spec.cpu],
bin: {
hunk: `./bin/${binaryName}`,
},
files: ["bin", "LICENSE"],
license: rootPackage.license,
publishConfig: {
access: "public",
},
};
}

/** Resolve a path under the generated prebuilt npm release directory. */
export function releaseNpmDir(repoRoot: string) {
return path.join(repoRoot, "dist", "release", "npm");
Expand Down
115 changes: 93 additions & 22 deletions scripts/smoke-prebuilt-install.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
#!/usr/bin/env bun

import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs";
import {
cpSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers";
import {
binaryFilenameForSpec,
getHostPlatformPackageSpec,
releaseNpmDir,
} from "./prebuilt-package-helpers";

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

try {
run(["npm", "pack", "--pack-destination", packageDir], {
cwd: path.join(releaseRoot, hostSpec.packageName),
packageDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-pack-"));
installDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-install-"));
smokeMetaDir = mkdtempSync(path.join(tempRoot, "hunk-prebuilt-meta-"));

const nodeBinary = Bun.spawnSync(["bash", "-lc", "command -v node"], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: process.env,
});
const resolvedNode = Buffer.from(nodeBinary.stdout).toString("utf8").trim();
if (nodeBinary.exitCode !== 0 || resolvedNode.length === 0) {
throw new Error("Could not resolve node on PATH for the prebuilt install smoke test.");
}
const bashBinary = Bun.spawnSync(["bash", "-lc", "command -v bash"], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: process.env,
});
const resolvedBash = Buffer.from(bashBinary.stdout).toString("utf8").trim();
if (bashBinary.exitCode !== 0 || resolvedBash.length === 0) {
throw new Error("Could not resolve bash on PATH for the prebuilt install smoke test.");
}
const nodeDir = path.dirname(resolvedNode);
const bashDir = path.dirname(resolvedBash);

run(["npm", "pack", "--pack-destination", packageDir], {
cwd: path.join(releaseRoot, "hunkdiff"),
cwd: path.join(releaseRoot, hostSpec.packageName),
});

const platformTarball = path.join(packageDir, `${hostSpec.packageName}-${packageVersion}.tgz`);

// Point a temp copy of the staged meta package at the local platform tarball.
// The real manifest uses semver ranges, but this smoke test runs before publish.
const smokePackageDir = path.join(smokeMetaDir, "hunkdiff");
cpSync(path.join(releaseRoot, "hunkdiff"), smokePackageDir, { recursive: true });
const smokeManifestPath = path.join(smokePackageDir, "package.json");
const smokeManifest = JSON.parse(readFileSync(smokeManifestPath, "utf8")) as {
optionalDependencies?: Record<string, string>;
};
smokeManifest.optionalDependencies = {
...smokeManifest.optionalDependencies,
[hostSpec.packageName]: `file:${platformTarball}`,
};
writeFileSync(smokeManifestPath, `${JSON.stringify(smokeManifest, null, 2)}\n`);

run(["npm", "pack", "--pack-destination", packageDir], {
cwd: smokePackageDir,
});
const metaTarball = path.join(packageDir, `hunkdiff-${packageVersion}.tgz`);

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

const sanitizedPath = `${path.join(installDir, "bin")}:${nodeDir}`;
const sanitizedPath = [path.join(installDir, "bin"), nodeDir, bashDir].join(":");
const installedHunk = path.join(installDir, "bin", "hunk");
const installedPlatformBinary = path.join(
installDir,
"lib",
"node_modules",
"hunkdiff",
"node_modules",
hostSpec.packageName,
"bin",
binaryFilenameForSpec(hostSpec),
);
const commandEnv = {
...process.env,
PATH: sanitizedPath,
};

if (process.platform !== "win32") {
const installedBinaryMode = statSync(installedPlatformBinary).mode & 0o777;
if ((installedBinaryMode & 0o111) === 0) {
throw new Error(
`Expected installed platform binary to keep execute bits, got mode ${installedBinaryMode.toString(8)} at ${installedPlatformBinary}`,
);
}
}

const help = run([installedHunk, "--help"], {
env: commandEnv,
});
Expand Down Expand Up @@ -112,6 +176,13 @@ try {

console.log(`Verified prebuilt npm install smoke test with ${hostSpec.packageName}`);
} finally {
rmSync(packageDir, { recursive: true, force: true });
rmSync(installDir, { recursive: true, force: true });
if (packageDir) {
rmSync(packageDir, { recursive: true, force: true });
}
if (installDir) {
rmSync(installDir, { recursive: true, force: true });
}
if (smokeMetaDir) {
rmSync(smokeMetaDir, { recursive: true, force: true });
}
}
14 changes: 2 additions & 12 deletions scripts/stage-prebuilt-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import path from "node:path";
import {
binaryFilenameForSpec,
buildOptionalDependencyMap,
buildPlatformPackageManifest,
getHostPlatformPackageSpec,
getPlatformPackageSpecByName,
releaseNpmDir,
Expand Down Expand Up @@ -122,18 +123,7 @@ function stagePlatformPackage(
chmodSync(stagedBinary, 0o755);
cpSync(path.join(repoRoot, "LICENSE"), path.join(packageDir, "LICENSE"));

writeJson(path.join(packageDir, "package.json"), {
name: spec.packageName,
version: rootPackage.version,
description: `${rootPackage.description} (${spec.os} ${spec.cpu} binary)`,
os: [spec.os === "windows" ? "win32" : spec.os],
cpu: [spec.cpu],
files: ["bin", "LICENSE"],
license: rootPackage.license,
publishConfig: {
access: "public",
},
});
writeJson(path.join(packageDir, "package.json"), buildPlatformPackageManifest(rootPackage, spec));
}

function collectArtifactSpecs(artifactRoot: string) {
Expand Down
Loading