diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84ecc186..575526b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,41 @@ jobs: fi hunk --help | grep 'Usage: hunk' + prebuilt-npm: + name: Verify prebuilt npm package (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Stage prebuilt npm packages + run: bun run build:prebuilt:npm + + - name: Verify staged prebuilt packs + run: bun run check:prebuilt-pack + + - name: Smoke test prebuilt global install + run: bun run smoke:prebuilt-install + build-bin: name: Build compiled binary runs-on: ubuntu-latest diff --git a/README.md b/README.md index 2de5c065..5904d450 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,14 @@ bun run build:npm bun run check:pack ``` +Stage the prototype prebuilt npm packages for the current host and smoke test the install path without Bun on `PATH`: + +```bash +bun run build:prebuilt:npm +bun run check:prebuilt-pack +bun run smoke:prebuilt-install +``` + ## License [MIT](LICENSE) diff --git a/bin/hunk.cjs b/bin/hunk.cjs index 79f5eaed..8c42dd40 100755 --- a/bin/hunk.cjs +++ b/bin/hunk.cjs @@ -1,32 +1,103 @@ #!/usr/bin/env node -const { spawnSync } = require("node:child_process"); +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); const path = require("node:path"); -const entrypoint = path.join(__dirname, "..", "dist", "npm", "main.js"); +function run(target, args) { + const result = childProcess.spawnSync(target, args, { + stdio: "inherit", + env: process.env, + }); -let bunBinary; + if (result.error) { + console.error(result.error.message); + process.exit(1); + } + + process.exit(typeof result.status === "number" ? result.status : 1); +} + +function hostCandidates() { + const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", + }; + const archMap = { + x64: "x64", + arm64: "arm64", + }; + + const platform = platformMap[os.platform()] || os.platform(); + const arch = archMap[os.arch()] || os.arch(); + const binary = platform === "windows" ? "hunk.exe" : "hunk"; -try { - bunBinary = require.resolve("bun/bin/bun.exe"); -} catch (error) { - console.error( - "Failed to resolve the bundled Bun runtime. Try reinstalling hunkdiff.", - ); - if (error && error.message) { - console.error(error.message); + if (platform === "darwin") { + if (arch === "arm64") return [{ packageName: "hunkdiff-darwin-arm64", binary }]; + if (arch === "x64") return [{ packageName: "hunkdiff-darwin-x64", binary }]; } - process.exit(1); + + if (platform === "linux") { + if (arch === "arm64") return [{ packageName: "hunkdiff-linux-arm64", binary }]; + if (arch === "x64") return [{ packageName: "hunkdiff-linux-x64", binary }]; + } + + return []; } -const result = spawnSync(bunBinary, [entrypoint, ...process.argv.slice(2)], { - stdio: "inherit", - env: process.env, -}); +function findInstalledBinary(startDir) { + let current = startDir; + + for (;;) { + const modulesDir = path.join(current, "node_modules"); + if (fs.existsSync(modulesDir)) { + for (const candidate of hostCandidates()) { + const resolved = path.join(modulesDir, candidate.packageName, "bin", candidate.binary); + if (fs.existsSync(resolved)) { + return resolved; + } + } + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function bundledBunRuntime() { + try { + return require.resolve("bun/bin/bun.exe"); + } catch { + return null; + } +} + +const overrideBinary = process.env.HUNK_BIN_PATH; +if (overrideBinary) { + run(overrideBinary, process.argv.slice(2)); +} + +const scriptDir = path.dirname(fs.realpathSync(__filename)); +const prebuiltBinary = findInstalledBinary(scriptDir); +if (prebuiltBinary) { + run(prebuiltBinary, process.argv.slice(2)); +} -if (result.error) { - console.error(result.error.message); - process.exit(1); +const bunBinary = bundledBunRuntime(); +if (bunBinary) { + const entrypoint = path.join(__dirname, "..", "dist", "npm", "main.js"); + run(bunBinary, [entrypoint, ...process.argv.slice(2)]); } -process.exit(typeof result.status === "number" ? result.status : 1); +const printablePackages = hostCandidates().map((candidate) => `"${candidate.packageName}"`).join(" or "); +console.error( + printablePackages.length > 0 + ? `Failed to locate a matching prebuilt Hunk binary. Try reinstalling hunkdiff or manually installing ${printablePackages}.` + : `Unsupported platform for prebuilt Hunk binaries: ${os.platform()} ${os.arch()}`, +); +process.exit(1); diff --git a/docs/prebuilt-binaries-plan.md b/docs/prebuilt-binaries-plan.md new file mode 100644 index 00000000..910c427f --- /dev/null +++ b/docs/prebuilt-binaries-plan.md @@ -0,0 +1,338 @@ +# npm distribution plan: prebuilt platform binaries + +Status: planned + +## Context + +`hunkdiff` currently publishes a small Node launcher that shells out to a bundled `bun` npm dependency. That solved the immediate install UX problem, but it still makes `npm i -g hunkdiff` slower than it needs to be because users install both: + +- the Hunk package +- a full Bun runtime package + +The better long-term shape is the pattern used by tools like `opencode-ai`: + +- a tiny top-level npm package +- platform-specific packages in `optionalDependencies` +- a small launcher that resolves and execs the matching binary + +## Findings from the opencode pattern + +From the published npm packages: + +- `opencode-ai` is a tiny meta package with a Node launcher in `bin/opencode` +- it declares many platform packages via `optionalDependencies` +- each platform package contains only a binary and a tiny `package.json` +- the launcher: + - maps `process.platform` / `process.arch` to package names + - handles Linux musl vs glibc + - handles x64 baseline fallbacks + - searches upward for `node_modules//bin/` + - prints a manual install hint when npm fails to install the matching optional dependency + +That is the right model for Hunk too. + +## Goals + +- keep npm package name `hunkdiff` +- keep installed CLI command `hunk` +- remove Bun as an end-user runtime dependency +- make fresh global installs faster and simpler +- preserve a normal `npm i -g hunkdiff` workflow +- keep development workflow Bun-based inside the repo + +## Non-goals for the first pass + +- Homebrew, apt, or standalone installer work +- changing Hunk's CLI UX +- adding Windows support unless the release pipeline is already straightforward +- solving every CPU baseline edge case on day one if a smaller supported matrix ships sooner + +## Recommended package model + +### Top-level package + +Keep the current root package as the published top-level package: + +- name: `hunkdiff` +- bin: `hunk` +- no Bun dependency +- add platform packages in `optionalDependencies` +- ship only the launcher, README, license, and minimal package metadata + +Why keep the root package as the published meta package? + +- no monorepo conversion required +- `npm pack` from the repo root still verifies the public package +- development dependencies stay dev-only and do not ship +- the current publishing flow changes less + +### Platform packages + +Create generated publish directories for packages like: + +- `hunkdiff-darwin-arm64` +- `hunkdiff-darwin-x64` +- `hunkdiff-linux-x64` +- `hunkdiff-linux-arm64` + +Possible later additions: + +- `hunkdiff-linux-x64-musl` +- `hunkdiff-linux-arm64-musl` +- `hunkdiff-windows-x64` +- `hunkdiff-windows-arm64` +- x64 baseline variants if Bun/runtime compatibility requires them + +Each platform package should contain only: + +- `bin/hunk` or `bin/hunk.exe` +- `package.json` +- optionally `README.md` or license if npm/release policy needs it + +Example platform manifest shape: + +```json +{ + "name": "hunkdiff-linux-x64", + "version": "0.3.0", + "os": ["linux"], + "cpu": ["x64"], + "files": ["bin"], + "bin": { + "hunk": "./bin/hunk" + }, + "license": "MIT" +} +``` + +## Launcher design + +Replace `bin/hunk.cjs` with a binary-resolving launcher. + +Responsibilities: + +1. map the current runtime to candidate package names +2. locate the installed platform package under `node_modules` +3. exec the binary with inherited stdio and argv +4. print a clear recovery message if the matching package is missing +5. optionally honor `HUNK_BIN_PATH` for debugging and local smoke tests + +### Candidate resolution order + +Recommended initial logic: + +- macOS: + - `hunkdiff-darwin-arm64` + - `hunkdiff-darwin-x64` +- Linux glibc: + - `hunkdiff-linux-arm64` + - `hunkdiff-linux-x64` +- Linux musl later, once those packages exist: + - prefer `*-musl` + - fall back to glibc package names only if proven safe +- Windows later: + - `hunkdiff-windows-x64` + - `hunkdiff-windows-arm64` + +For Linux, add musl detection before introducing musl package names: + +- `/etc/alpine-release` +- `ldd --version` containing `musl` + +For x64 baseline variants, copy opencode's approach only if benchmarking or runtime failures justify the extra package matrix. + +## Build strategy + +## Recommendation: native per-platform builds, not cross-compilation-first + +Bun can compile standalone executables, but cross-platform packaging is the risky part of this migration. The plan should assume native builds in CI first unless Bun cross-compilation is proven reliable for every target we care about. + +That means: + +- build Linux binaries on Linux runners +- build macOS binaries on macOS runners +- add Windows only when its build and smoke test path is stable + +This is more operationally boring, which is good for release infrastructure. + +## Suggested repository layout + +Generated artifacts only; no large checked-in binary packages: + +```text +bin/ + hunk.cjs # top-level launcher +scripts/ + build-bin.sh # local single-platform dev build + build-platform-binary.ts # CI/release build helper + stage-platform-package.ts # create dist/npm/ + check-top-level-pack.ts + check-platform-pack.ts + publish-npm-release.ts # optional later automation + +dist/ + release/ + binaries/ + hunk-linux-x64 + hunk-darwin-arm64 + npm/ + hunkdiff/ + hunkdiff-linux-x64/ + hunkdiff-darwin-arm64/ +``` +``` + +The checked-in repo should contain templates and scripts, not prebuilt binaries. + +## Release workflow + +## Phase 0: measure before changing + +Before implementing the new model, capture a baseline for the current Bun-backed npm install: + +- fresh `npm i -g hunkdiff` wall-clock time on macOS and Linux +- installed package footprint on disk +- cold `hunk --help` startup time + +This avoids shipping a more complex release system without proving the user-visible improvement. + +## Phase 1: prototype the packaging model locally + +Implement locally first: + +1. remove `bun` from top-level runtime dependencies +2. replace `bin/hunk.cjs` with a binary resolver +3. add a script that stages one local platform package from `dist/hunk` +4. test a local install using: + - top-level package tarball + - staged platform tarball + - sanitized PATH without Bun + +This proves the launcher and package layout before CI automation. + +## Phase 2: build a minimal supported matrix + +Recommended first public matrix: + +- macOS arm64 +- macOS x64 +- Linux x64 + +Possible addition if runner support is easy: + +- Linux arm64 + +Why this scope: + +- covers the most likely early adopters +- keeps the first release simpler than a full musl/baseline/Windows matrix +- lets us ship a meaningful improvement before the packaging matrix explodes + +## Phase 3: automate staged package creation + +For a tagged release: + +1. each matrix job builds one compiled binary +2. each matrix job uploads its artifact +3. a packaging job downloads all artifacts +4. the packaging job stages platform package directories with aligned versions +5. the packaging job verifies every package with `npm pack --dry-run` +6. publish platform packages first +7. publish the top-level `hunkdiff` package last + +Publishing order matters because the top-level package references exact-version optional dependencies. + +## Phase 4: add smoke coverage + +CI should verify: + +- top-level `npm pack --dry-run` contents +- each platform package tarball contains only expected files +- launcher resolution logic on supported platforms +- fresh global install without Bun on PATH +- `hunk --help` works after install + +Minimum CI recommendation: + +- Ubuntu smoke job +- macOS smoke job + +## Phase 5: expand compatibility only when needed + +After the initial rollout, add more variants based on real failures or demand: + +- Linux musl packages +- Linux arm64 if not in phase 2 +- Windows packages +- x64 baseline variants for older CPUs + +## Package manifest changes + +Top-level `package.json` should eventually look more like this: + +```json +{ + "name": "hunkdiff", + "bin": { + "hunk": "./bin/hunk.cjs" + }, + "files": ["bin", "README.md", "LICENSE"], + "optionalDependencies": { + "hunkdiff-darwin-arm64": "0.3.0", + "hunkdiff-darwin-x64": "0.3.0", + "hunkdiff-linux-x64": "0.3.0" + } +} +``` + +Notably: + +- remove `dist/npm` from the published package +- remove runtime dependency on `bun` +- move packaging verification from "does the JS bundle exist" to "does the launcher and package graph exist" + +## CI and release files to add or change + +Likely changes: + +- `bin/hunk.cjs` + - change from Bun launcher to binary resolver +- `package.json` + - remove `bun` dependency + - add `optionalDependencies` + - shrink published `files` +- `scripts/check-pack.ts` + - verify top-level meta package shape +- new script for platform tarball verification +- `.github/workflows/ci.yml` + - add macOS install smoke test job +- new `.github/workflows/release.yml` + - build matrix, stage packages, publish to npm on tag + +## Risks + +- release complexity increases meaningfully +- npm optional dependency behavior can vary across package managers +- Linux musl/glibc detection must be correct once those packages exist +- Windows support may require launcher and path special cases +- Bun-compiled binaries may still be large, so install-time improvement must be measured, not assumed + +## Rollout recommendation + +Use a two-step rollout: + +1. prototype behind a branch and measure install/startup improvements +2. ship the binary-package model only when it clearly beats the Bun-backed package on at least macOS and Linux x64 + +If the gains are modest, keep the release system simple. If the gains are strong, continue to the full opencode-style release flow. + +## Acceptance criteria + +This plan is complete when Hunk can demonstrate all of the following: + +- `npm i -g hunkdiff` works without installing Bun as a runtime dependency +- supported users receive exactly one matching platform binary package plus the tiny meta package +- fresh install time improves versus the Bun-backed path on the tested platforms +- `hunk --help` works on a clean machine after install +- CI verifies install smoke tests on at least Linux and macOS +- README documents supported install targets and unsupported platforms clearly diff --git a/package.json b/package.json index 63fbfa2b..1ba1837c 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,14 @@ "dev": "bun --watch src/main.tsx", "build:npm": "bash ./scripts/build-npm.sh", "build:bin": "bash ./scripts/build-bin.sh", + "build:prebuilt:npm": "bun run build:bin && bun run ./scripts/stage-prebuilt-npm.ts", "install:bin": "bash ./scripts/install-bin.sh", "typecheck": "tsc --noEmit", "test": "bun test", "test:tty-smoke": "bun test test/tty-render-smoke.test.ts", "check:pack": "bun run ./scripts/check-pack.ts", + "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts", + "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts", "prepack": "bun run build:npm", "bench:bootstrap-load": "bun run test/bootstrap-load-benchmark.ts", "bench:highlight-prefetch": "bun run test/adjacent-highlight-prefetch-benchmark.ts", diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts new file mode 100644 index 00000000..26e02341 --- /dev/null +++ b/scripts/check-prebuilt-pack.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun + +import path from "node:path"; +import { getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers"; + +interface PackedFile { + path: string; +} + +interface PackResult { + name: string; + version: string; + files: PackedFile[]; +} + +function runPackDryRun(cwd: string) { + const proc = Bun.spawnSync(["npm", "pack", "--dry-run", "--json"], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8").trim(); + const stderr = Buffer.from(proc.stderr).toString("utf8").trim(); + + if (proc.exitCode !== 0) { + throw new Error(stderr || stdout || `npm pack --dry-run failed in ${cwd}`); + } + + const jsonMatch = stdout.match(/(\[\s*\{[\s\S]*\}\s*\])\s*$/); + const jsonText = jsonMatch?.[1]; + if (!jsonText) { + throw new Error(`Could not find npm pack JSON output for ${cwd}. Full stdout:\n${stdout}`); + } + + const [pack] = JSON.parse(jsonText) as PackResult[]; + if (!pack) { + throw new Error(`npm pack --dry-run returned no result for ${cwd}`); + } + + return pack; +} + +function assertPaths(pack: PackResult, requiredPaths: string[], forbiddenPrefixes: string[] = []) { + const publishedPaths = new Set(pack.files.map((file) => file.path)); + + for (const requiredPath of requiredPaths) { + if (!publishedPaths.has(requiredPath)) { + throw new Error(`Expected ${pack.name} to include ${requiredPath}.`); + } + } + + for (const file of pack.files) { + if (forbiddenPrefixes.some((prefix) => file.path.startsWith(prefix))) { + throw new Error(`Unexpected file in ${pack.name}: ${file.path}`); + } + } +} + +const repoRoot = path.resolve(import.meta.dir, ".."); +const releaseRoot = releaseNpmDir(repoRoot); +const hostSpec = getHostPlatformPackageSpec(); +const metaPack = runPackDryRun(path.join(releaseRoot, "hunkdiff")); +const hostPack = runPackDryRun(path.join(releaseRoot, hostSpec.packageName)); + +assertPaths(metaPack, ["bin/hunk.cjs", "README.md", "LICENSE", "package.json"]); +assertPaths(hostPack, ["bin/hunk", "LICENSE", "package.json"]); + +console.log(`Verified prebuilt npm packages for ${metaPack.version}: ${metaPack.name} + ${hostPack.name}`); diff --git a/scripts/prebuilt-package-helpers.ts b/scripts/prebuilt-package-helpers.ts new file mode 100644 index 00000000..3e82ba1c --- /dev/null +++ b/scripts/prebuilt-package-helpers.ts @@ -0,0 +1,68 @@ +#!/usr/bin/env bun + +import os from "node:os"; +import path from "node:path"; + +export type SupportedPlatform = "darwin" | "linux" | "windows"; +export type SupportedArch = "x64" | "arm64"; + +export interface PlatformPackageSpec { + packageName: string; + os: SupportedPlatform; + cpu: SupportedArch; + binaryName: string; + binaryRelativePath: string; +} + +const PLATFORM_NAME_MAP: Partial> = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; + +const ARCH_NAME_MAP: Partial> = { + x64: "x64", + arm64: "arm64", +}; + +export const PLATFORM_PACKAGE_MATRIX: PlatformPackageSpec[] = [ + { packageName: "hunkdiff-darwin-arm64", os: "darwin", cpu: "arm64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, + { packageName: "hunkdiff-darwin-x64", os: "darwin", cpu: "x64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, + { packageName: "hunkdiff-linux-arm64", os: "linux", cpu: "arm64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, + { packageName: "hunkdiff-linux-x64", os: "linux", cpu: "x64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, +] as const; + +/** Return the Hunk package spec that matches the current machine. */ +export function getHostPlatformPackageSpec() { + const normalizedPlatform = PLATFORM_NAME_MAP[os.platform()]; + if (!normalizedPlatform) { + throw new Error(`Unsupported host platform for prebuilt packaging: ${os.platform()}`); + } + + const normalizedArch = ARCH_NAME_MAP[os.arch()]; + if (!normalizedArch) { + throw new Error(`Unsupported host architecture for prebuilt packaging: ${os.arch()}`); + } + + const spec = PLATFORM_PACKAGE_MATRIX.find((candidate) => candidate.os === normalizedPlatform && candidate.cpu === normalizedArch); + if (!spec) { + throw new Error(`No prebuilt package spec matches ${normalizedPlatform}/${normalizedArch}`); + } + + return spec; +} + +/** Build the optional dependency map for the top-level hunkdiff package. */ +export function buildOptionalDependencyMap(version: string) { + return Object.fromEntries(PLATFORM_PACKAGE_MATRIX.map((spec) => [spec.packageName, version])); +} + +/** Return the executable filename for a platform package. */ +export function binaryFilenameForSpec(spec: PlatformPackageSpec) { + return spec.os === "windows" ? `${spec.binaryName}.exe` : spec.binaryName; +} + +/** 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 new file mode 100644 index 00000000..273f5fe0 --- /dev/null +++ b/scripts/smoke-prebuilt-install.ts @@ -0,0 +1,86 @@ +#!/usr/bin/env bun + +import { mkdtempSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers"; + +function run(command: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) { + const proc = Bun.spawnSync(command, { + cwd: options?.cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: options?.env ?? process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + if (proc.exitCode !== 0) { + throw new Error(`${command.join(" ")} failed with exit ${proc.exitCode}\n${stderr || stdout}`.trim()); + } + + return { stdout, stderr }; +} + +const repoRoot = path.resolve(import.meta.dir, ".."); +const packageVersion = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()).version as string; +const releaseRoot = releaseNpmDir(repoRoot); +const hostSpec = getHostPlatformPackageSpec(); +const packageDir = mkdtempSync(path.join(os.tmpdir(), "hunk-prebuilt-pack-")); +const installDir = mkdtempSync(path.join(os.tmpdir(), "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); + +try { + run(["npm", "pack", "--pack-destination", packageDir], { cwd: path.join(releaseRoot, hostSpec.packageName) }); + run(["npm", "pack", "--pack-destination", packageDir], { cwd: path.join(releaseRoot, "hunkdiff") }); + + const platformTarball = path.join(packageDir, `${hostSpec.packageName}-${packageVersion}.tgz`); + 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 installedHunk = path.join(installDir, "bin", "hunk"); + const help = run([installedHunk, "--help"], { + env: { + ...process.env, + PATH: sanitizedPath, + }, + }); + + if (help.stdout.includes("Usage: hunk") === false) { + throw new Error(`Expected help output to include 'Usage: hunk'.\n${help.stdout}`); + } + + const bunCheck = Bun.spawnSync( + [resolvedNode, "-e", "const {spawnSync}=require('node:child_process'); process.exit(spawnSync('bun',['--version'],{stdio:'ignore'}).status===0?1:0);"] , + { + env: { + ...process.env, + PATH: sanitizedPath, + }, + }, + ); + + if (bunCheck.exitCode !== 0) { + throw new Error("bun unexpectedly available on the prebuilt install smoke-test PATH"); + } + + console.log(`Verified prebuilt npm install smoke test with ${hostSpec.packageName}`); +} finally { + rmSync(packageDir, { recursive: true, force: true }); + rmSync(installDir, { recursive: true, force: true }); +} diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts new file mode 100644 index 00000000..8f197d2b --- /dev/null +++ b/scripts/stage-prebuilt-npm.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env bun + +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { binaryFilenameForSpec, buildOptionalDependencyMap, getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers"; + +type RootPackageJson = { + name: string; + version: string; + description?: string; + keywords?: string[]; + repository?: unknown; + homepage?: string; + bugs?: unknown; + license?: string; + engines?: Record; +}; + +const repoRoot = path.resolve(import.meta.dir, ".."); +const rootPackage = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) as RootPackageJson; +const hostSpec = getHostPlatformPackageSpec(); +const hostBinaryName = binaryFilenameForSpec(hostSpec); +const compiledBinary = path.join(repoRoot, "dist", "hunk"); +const releaseRoot = releaseNpmDir(repoRoot); +const metaDir = path.join(releaseRoot, rootPackage.name); +const hostPackageDir = path.join(releaseRoot, hostSpec.packageName); + +if (!existsSync(compiledBinary)) { + throw new Error(`Missing compiled binary at ${compiledBinary}. Run \`bun run build:bin\` first.`); +} + +rmSync(releaseRoot, { recursive: true, force: true }); +mkdirSync(releaseRoot, { recursive: true }); + +mkdirSync(path.join(metaDir, "bin"), { recursive: true }); +cpSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); +cpSync(path.join(repoRoot, "README.md"), path.join(metaDir, "README.md")); +cpSync(path.join(repoRoot, "LICENSE"), path.join(metaDir, "LICENSE")); + +writeFileSync( + path.join(metaDir, "package.json"), + JSON.stringify( + { + name: rootPackage.name, + version: rootPackage.version, + description: rootPackage.description, + bin: { + hunk: "./bin/hunk.cjs", + }, + files: ["bin", "README.md", "LICENSE"], + keywords: rootPackage.keywords, + repository: rootPackage.repository, + homepage: rootPackage.homepage, + bugs: rootPackage.bugs, + engines: rootPackage.engines, + optionalDependencies: buildOptionalDependencyMap(rootPackage.version), + license: rootPackage.license, + publishConfig: { + access: "public", + }, + }, + null, + 2, + ) + "\n", +); + +mkdirSync(path.join(hostPackageDir, "bin"), { recursive: true }); +cpSync(compiledBinary, path.join(hostPackageDir, "bin", hostBinaryName)); +cpSync(path.join(repoRoot, "LICENSE"), path.join(hostPackageDir, "LICENSE")); + +writeFileSync( + path.join(hostPackageDir, "package.json"), + JSON.stringify( + { + name: hostSpec.packageName, + version: rootPackage.version, + description: `${rootPackage.description} (${hostSpec.os} ${hostSpec.cpu} binary)`, + os: [hostSpec.os === "windows" ? "win32" : hostSpec.os], + cpu: [hostSpec.cpu], + files: ["bin", "LICENSE"], + bin: { + hunk: `./bin/${hostBinaryName}`, + }, + license: rootPackage.license, + publishConfig: { + access: "public", + }, + }, + null, + 2, + ) + "\n", +); + +console.log(`Staged prebuilt npm packages in ${releaseRoot}`); +console.log(`- ${metaDir}`); +console.log(`- ${hostPackageDir}`); diff --git a/test/prebuilt-package-helpers.test.ts b/test/prebuilt-package-helpers.test.ts new file mode 100644 index 00000000..6a838bc7 --- /dev/null +++ b/test/prebuilt-package-helpers.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { PLATFORM_PACKAGE_MATRIX, binaryFilenameForSpec, buildOptionalDependencyMap } from "../scripts/prebuilt-package-helpers"; + +describe("prebuilt package helpers", () => { + test("buildOptionalDependencyMap includes every supported platform package at one version", () => { + const version = "9.9.9"; + const dependencies = buildOptionalDependencyMap(version); + + expect(Object.keys(dependencies).sort()).toEqual(PLATFORM_PACKAGE_MATRIX.map((spec) => spec.packageName).sort()); + expect(new Set(Object.values(dependencies))).toEqual(new Set([version])); + }); + + test("binaryFilenameForSpec keeps unix package binaries extensionless", () => { + expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[0]!)).toBe("hunk"); + expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[1]!)).toBe("hunk"); + expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[2]!)).toBe("hunk"); + expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[3]!)).toBe("hunk"); + }); +});