diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8a152..3fb1a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/prebuilt-package-helpers.test.ts b/scripts/prebuilt-package-helpers.test.ts index e112bde..25d5e8d 100644 --- a/scripts/prebuilt-package-helpers.test.ts +++ b/scripts/prebuilt-package-helpers.test.ts @@ -3,6 +3,7 @@ import { PLATFORM_PACKAGE_MATRIX, binaryFilenameForSpec, buildOptionalDependencyMap, + buildPlatformPackageManifest, getHostPlatformPackageSpec, getPlatformPackageSpecByName, getPlatformPackageSpecForHost, @@ -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"]); + }); + test("sortPlatformPackageSpecs keeps package publish order stable", () => { const reversed = [...PLATFORM_PACKAGE_MATRIX].reverse(); expect(sortPlatformPackageSpecs(reversed).map((spec) => spec.packageName)).toEqual([ diff --git a/scripts/prebuilt-package-helpers.ts b/scripts/prebuilt-package-helpers.ts index a184c98..1a473ec 100644 --- a/scripts/prebuilt-package-helpers.ts +++ b/scripts/prebuilt-package-helpers.ts @@ -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"); diff --git a/scripts/smoke-prebuilt-install.ts b/scripts/smoke-prebuilt-install.ts index 43e24f4..2266e29 100644 --- a/scripts/smoke-prebuilt-install.ts +++ b/scripts/smoke-prebuilt-install.ts @@ -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, { @@ -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; + }; + 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, }); @@ -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 }); + } } diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts index 5c89bd2..2894e75 100644 --- a/scripts/stage-prebuilt-npm.ts +++ b/scripts/stage-prebuilt-npm.ts @@ -14,6 +14,7 @@ import path from "node:path"; import { binaryFilenameForSpec, buildOptionalDependencyMap, + buildPlatformPackageManifest, getHostPlatformPackageSpec, getPlatformPackageSpecByName, releaseNpmDir, @@ -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) {