diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfc532e..18e82d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,12 @@ All notable user-visible changes to Hunk are documented in this file. - Added Windows x64 prebuilt artifact publishing to the release workflow. - Added Nix flake app outputs for `nix run` and a named `hunk` package output. +- Documented native Windows support in the README and contributor guide. ### Changed +- Ported `build:npm`, `build:bin`, and `install:bin` from bash scripts to cross-platform Bun-runnable TypeScript so native Windows contributors no longer need Git Bash to build or install Hunk locally. + ### Fixed ## [0.12.0-beta.1] - 2026-05-10 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8d21869..91956e84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,7 @@ Requirements: - Bun 1.3+ - Node.js 18+ - Git +- macOS, Linux, or Windows. The Windows path uses native Node/Bun on Windows 10/11 (x64); no WSL or Git Bash required. > Nix users can use `nix develop` or [direnv](https://direnv.net/) to enter a development shell. diff --git a/README.md b/README.md index d1bdce87..8923ce54 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ brew install modem-dev/tap/hunk Requirements: - Node.js 18+ -- macOS or Linux +- macOS, Linux, or Windows - Git recommended for most workflows > Nix users can use the `default` package exported in `flake.nix` instead. See [nix/README.md](./nix/README.md) for details. diff --git a/package.json b/package.json index 8f7845db..313acabf 100644 --- a/package.json +++ b/package.json @@ -46,12 +46,12 @@ "scripts": { "start": "bun run src/main.tsx", "dev": "bun --watch src/main.tsx", - "build:npm": "bash ./scripts/build-npm.sh", - "build:bin": "bash ./scripts/build-bin.sh", + "build:npm": "bun run ./scripts/build-npm.ts", + "build:bin": "bun run ./scripts/build-bin.ts", "build:prebuilt:npm": "bun run build:npm && bun run build:bin && bun run ./scripts/stage-prebuilt-npm.ts", "build:prebuilt:artifact": "bun run build:bin && bun run ./scripts/build-prebuilt-artifact.ts", "stage:prebuilt:release": "bun run build:npm && bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts", - "install:bin": "bash ./scripts/install-bin.sh", + "install:bin": "bun run ./scripts/install-bin.ts", "typecheck": "tsc --noEmit", "format": "oxfmt --write .", "format:check": "oxfmt --check .", diff --git a/scripts/build-bin.sh b/scripts/build-bin.sh deleted file mode 100644 index c4c802fe..00000000 --- a/scripts/build-bin.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -dist_dir="${repo_root}/dist" -outfile="${dist_dir}/hunk" -legacy_outfile="${dist_dir}/otdiff" - -mkdir -p "${dist_dir}" -rm -f "${legacy_outfile}" - -BUN_TMPDIR="${repo_root}/.bun-tmp" \ -BUN_INSTALL="${repo_root}/.bun-install" \ -bun build --compile "${repo_root}/src/main.tsx" --outfile "${outfile}" - -printf 'Built %s\n' "${outfile}" diff --git a/scripts/build-bin.ts b/scripts/build-bin.ts new file mode 100644 index 00000000..2158d454 --- /dev/null +++ b/scripts/build-bin.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env bun + +import { mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; + +const repoRoot = path.resolve(import.meta.dir, ".."); +const distDir = path.join(repoRoot, "dist"); +const binaryName = process.platform === "win32" ? "hunk.exe" : "hunk"; +const outfile = path.join(distDir, binaryName); +const legacyOutfile = path.join(distDir, process.platform === "win32" ? "otdiff.exe" : "otdiff"); + +mkdirSync(distDir, { recursive: true }); +rmSync(legacyOutfile, { force: true }); + +const proc = Bun.spawnSync( + ["bun", "build", "--compile", path.join(repoRoot, "src", "main.tsx"), "--outfile", outfile], + { + cwd: repoRoot, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env: { + ...process.env, + BUN_TMPDIR: path.join(repoRoot, ".bun-tmp"), + BUN_INSTALL: path.join(repoRoot, ".bun-install"), + }, + }, +); + +if (proc.exitCode !== 0) { + throw new Error(`bun build --compile failed with exit ${proc.exitCode}`); +} + +console.log(`Built ${outfile}`); diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh deleted file mode 100644 index 31697a8a..00000000 --- a/scripts/build-npm.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -outdir="${repo_root}/dist/npm" -types_outdir="${repo_root}/dist/npm-types" - -rm -rf "${outdir}" -rm -rf "${types_outdir}" -mkdir -p "${outdir}/opentui" - -BUN_TMPDIR="${repo_root}/.bun-tmp" \ -BUN_INSTALL="${repo_root}/.bun-install" \ - bun build "${repo_root}/src/main.tsx" \ - --target bun \ - --format esm \ - --outdir "${outdir}" \ - --entry-naming main.js - -chmod 0755 "${outdir}/main.js" - -BUN_TMPDIR="${repo_root}/.bun-tmp" \ -BUN_INSTALL="${repo_root}/.bun-install" \ - bun build "${repo_root}/src/opentui/index.ts" \ - --target node \ - --format esm \ - --external react \ - --external react/jsx-runtime \ - --external react/jsx-dev-runtime \ - --external @opentui/core \ - --external @opentui/react \ - --external @opentui/react/jsx-runtime \ - --external @opentui/react/jsx-dev-runtime \ - --external @pierre/diffs \ - --outdir "${outdir}/opentui" \ - --entry-naming index.js - -bun x tsc -p "${repo_root}/tsconfig.opentui.json" - -cp "${types_outdir}/opentui/"*.d.ts "${outdir}/opentui/" -rm -rf "${types_outdir}" - -printf 'Built %s\n' "${outdir}/main.js" -printf 'Built %s\n' "${outdir}/opentui/index.js" diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts new file mode 100644 index 00000000..c4be9c6d --- /dev/null +++ b/scripts/build-npm.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env bun + +import { chmodSync, copyFileSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import path from "node:path"; + +const repoRoot = path.resolve(import.meta.dir, ".."); +const outdir = path.join(repoRoot, "dist", "npm"); +const typesOutdir = path.join(repoRoot, "dist", "npm-types"); +const opentuiOutdir = path.join(outdir, "opentui"); +const opentuiTypesDir = path.join(typesOutdir, "opentui"); + +const bunEnv = { + ...process.env, + BUN_TMPDIR: path.join(repoRoot, ".bun-tmp"), + BUN_INSTALL: path.join(repoRoot, ".bun-install"), +}; + +function runBun(args: string[]) { + const proc = Bun.spawnSync(["bun", ...args], { + cwd: repoRoot, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env: bunEnv, + }); + + if (proc.exitCode !== 0) { + throw new Error(`bun ${args.join(" ")} failed with exit ${proc.exitCode}`); + } +} + +rmSync(outdir, { recursive: true, force: true }); +rmSync(typesOutdir, { recursive: true, force: true }); +mkdirSync(opentuiOutdir, { recursive: true }); + +runBun([ + "build", + path.join(repoRoot, "src", "main.tsx"), + "--target", + "bun", + "--format", + "esm", + "--outdir", + outdir, + "--entry-naming", + "main.js", +]); + +const mainJs = path.join(outdir, "main.js"); +// chmod is a no-op on Windows; preserve exec bits on Unix so the bin runs in npm-installed packages. +if (process.platform !== "win32") { + chmodSync(mainJs, 0o755); +} + +runBun([ + "build", + path.join(repoRoot, "src", "opentui", "index.ts"), + "--target", + "node", + "--format", + "esm", + "--external", + "react", + "--external", + "react/jsx-runtime", + "--external", + "react/jsx-dev-runtime", + "--external", + "@opentui/core", + "--external", + "@opentui/react", + "--external", + "@opentui/react/jsx-runtime", + "--external", + "@opentui/react/jsx-dev-runtime", + "--external", + "@pierre/diffs", + "--outdir", + opentuiOutdir, + "--entry-naming", + "index.js", +]); + +runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.opentui.json")]); + +for (const entry of readdirSync(opentuiTypesDir)) { + if (entry.endsWith(".d.ts")) { + copyFileSync(path.join(opentuiTypesDir, entry), path.join(opentuiOutdir, entry)); + } +} + +rmSync(typesOutdir, { recursive: true, force: true }); + +console.log(`Built ${mainJs}`); +console.log(`Built ${path.join(opentuiOutdir, "index.js")}`); diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 934846ab..d0baae92 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -1,5 +1,7 @@ #!/usr/bin/env bun +import { npmCommand } from "./script-helpers"; + interface PackedFile { path: string; size: number; @@ -13,7 +15,7 @@ interface PackResult { files: PackedFile[]; } -const proc = Bun.spawnSync(["npm", "pack", "--dry-run", "--json"], { +const proc = Bun.spawnSync([npmCommand, "pack", "--dry-run", "--json"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts index 8b34a79e..808e6c40 100644 --- a/scripts/check-prebuilt-pack.ts +++ b/scripts/check-prebuilt-pack.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import { releaseNpmDir } from "./prebuilt-package-helpers"; +import { npmCommand } from "./script-helpers"; interface PackedFile { path: string; @@ -15,7 +16,7 @@ interface PackResult { } function runPackDryRun(cwd: string) { - const proc = Bun.spawnSync(["npm", "pack", "--dry-run", "--json"], { + const proc = Bun.spawnSync([npmCommand, "pack", "--dry-run", "--json"], { cwd, stdin: "ignore", stdout: "pipe", diff --git a/scripts/install-bin.sh b/scripts/install-bin.sh deleted file mode 100644 index cc1d1c76..00000000 --- a/scripts/install-bin.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -set -Eeuo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -binary_path="${repo_root}/dist/hunk" -install_dir="${HUNK_INSTALL_DIR:-${HOME}/.local/bin}" -install_path="${install_dir}/hunk" -legacy_install_path="${install_dir}/otdiff" - -bash "${repo_root}/scripts/build-bin.sh" - -mkdir -p "${install_dir}" -install -m 0755 "${binary_path}" "${install_path}" -rm -f "${legacy_install_path}" - -printf 'Installed %s\n' "${install_path}" - -case ":${PATH}:" in - *":${install_dir}:"*) ;; - *) - printf 'Warning: %s is not on PATH\n' "${install_dir}" >&2 - ;; -esac diff --git a/scripts/install-bin.ts b/scripts/install-bin.ts new file mode 100644 index 00000000..e531d088 --- /dev/null +++ b/scripts/install-bin.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env bun + +import { chmodSync, copyFileSync, mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const repoRoot = path.resolve(import.meta.dir, ".."); +const isWindows = process.platform === "win32"; +const binaryName = isWindows ? "hunk.exe" : "hunk"; +const legacyBinaryName = isWindows ? "otdiff.exe" : "otdiff"; +const binaryPath = path.join(repoRoot, "dist", binaryName); + +function defaultInstallDir() { + if (isWindows) { + const base = process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"); + return path.join(base, "Programs", "hunk"); + } + + return path.join(os.homedir(), ".local", "bin"); +} + +const installDir = process.env.HUNK_INSTALL_DIR ?? defaultInstallDir(); +const installPath = path.join(installDir, binaryName); +const legacyInstallPath = path.join(installDir, legacyBinaryName); + +const buildScript = path.join(repoRoot, "scripts", "build-bin.ts"); +const build = Bun.spawnSync(["bun", "run", buildScript], { + cwd: repoRoot, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env: process.env, +}); + +if (build.exitCode !== 0) { + throw new Error(`scripts/build-bin.ts failed with exit ${build.exitCode}`); +} + +mkdirSync(installDir, { recursive: true }); +copyFileSync(binaryPath, installPath); +if (!isWindows) { + chmodSync(installPath, 0o755); +} +rmSync(legacyInstallPath, { force: true }); + +console.log(`Installed ${installPath}`); + +const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); +const installDirOnPath = pathEntries.some((entry) => { + // Windows paths are case-insensitive; normalize both sides for the comparison. + const normalizedEntry = isWindows ? path.normalize(entry).toLowerCase() : path.normalize(entry); + const normalizedInstallDir = isWindows + ? path.normalize(installDir).toLowerCase() + : path.normalize(installDir); + return normalizedEntry === normalizedInstallDir; +}); + +if (!installDirOnPath) { + console.warn(`Warning: ${installDir} is not on PATH`); +} diff --git a/scripts/publish-prebuilt-npm.ts b/scripts/publish-prebuilt-npm.ts index 1d6ebcab..d830c05e 100644 --- a/scripts/publish-prebuilt-npm.ts +++ b/scripts/publish-prebuilt-npm.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { releaseNpmDir } from "./prebuilt-package-helpers"; +import { npmCommand } from "./script-helpers"; type PackageJson = { name: string; @@ -41,7 +42,7 @@ function parseArgs(argv: string[]) { } function npmViewExists(name: string, version: string) { - const proc = Bun.spawnSync(["npm", "view", `${name}@${version}`, "version"], { + const proc = Bun.spawnSync([npmCommand, "view", `${name}@${version}`, "version"], { stdin: "ignore", stdout: "pipe", stderr: "ignore", @@ -70,7 +71,7 @@ function publishDirectory(directory: string, dryRun: boolean, npmTag: string) { args.push("--dry-run"); } - const proc = Bun.spawnSync(["npm", ...args], { + const proc = Bun.spawnSync([npmCommand, ...args], { cwd: directory, stdin: "ignore", stdout: "inherit", diff --git a/scripts/script-helpers.ts b/scripts/script-helpers.ts new file mode 100644 index 00000000..428b1763 --- /dev/null +++ b/scripts/script-helpers.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env bun + +/** + * Shared cross-platform helpers for Bun-driven repo scripts. + * + * On Windows, Node ships `npm` (a shell-script shim) alongside `npm.cmd`. Bun + * (and Node `child_process` without `shell: true`) cannot execute the shim + * directly, so we must spawn the `.cmd` wrapper instead. The helpers below + * give scripts one place to resolve those names. + */ + +/** Command name to invoke `npm` from Bun.spawn on the current platform. */ +export const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + +/** + * Build a child-process env that overrides PATH cleanly on every platform. + * + * Windows environment variables are case-insensitive, but `{ ...process.env }` + * preserves whatever case Bun reported (often `Path`). Setting `PATH: ...` on + * top of that produces an env object with both `Path` (the original system + * value) and `PATH` (the sanitized value); the child inherits both, and + * Windows resolves lookups against the un-normalized union, defeating any + * attempt to scope PATH. We strip every case-variant first, then set PATH. + */ +export function envWithPath( + path: string, + base: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(base)) { + if (key.toLowerCase() !== "path") { + next[key] = value; + } + } + next.PATH = path; + return next; +} diff --git a/scripts/smoke-prebuilt-install.ts b/scripts/smoke-prebuilt-install.ts index 79c46d7c..4c9bcceb 100644 --- a/scripts/smoke-prebuilt-install.ts +++ b/scripts/smoke-prebuilt-install.ts @@ -16,6 +16,7 @@ import { getHostPlatformPackageSpec, releaseNpmDir, } from "./prebuilt-package-helpers"; +import { envWithPath, npmCommand } from "./script-helpers"; function run(command: string[], options?: { cwd?: string; env?: NodeJS.ProcessEnv }) { const proc = Bun.spawnSync(command, { @@ -80,9 +81,11 @@ try { const nodePath = commandPath("node"); const nodeDir = path.dirname(nodePath); - const bashDir = commandDirectory("bash"); + // bash is required on Unix where the npm-installed wrapper shells out via `#!/usr/bin/env bash`, + // but the Windows `hunk.cmd` shim does not need bash on PATH. + const bashDir = process.platform === "win32" ? undefined : commandDirectory("bash"); - run(["npm", "pack", "--pack-destination", packageDir], { + run([npmCommand, "pack", "--pack-destination", packageDir], { cwd: path.join(releaseRoot, hostSpec.packageName), }); @@ -102,19 +105,19 @@ try { }; writeFileSync(smokeManifestPath, `${JSON.stringify(smokeManifest, null, 2)}\n`); - run(["npm", "pack", "--pack-destination", packageDir], { + run([npmCommand, "pack", "--pack-destination", packageDir], { cwd: smokePackageDir, }); const metaTarball = path.join(packageDir, `hunkdiff-${packageVersion}.tgz`); - run(["npm", "install", "-g", "--prefix", installDir, metaTarball]); + run([npmCommand, "install", "-g", "--prefix", installDir, metaTarball]); const installedBinDir = process.platform === "win32" ? installDir : path.join(installDir, "bin"); const installedPackageRoot = process.platform === "win32" ? path.join(installDir, "node_modules", "hunkdiff") : path.join(installDir, "lib", "node_modules", "hunkdiff"); - const sanitizedPath = [installedBinDir, nodeDir, bashDir].join(path.delimiter); + const sanitizedPath = [installedBinDir, nodeDir, bashDir].filter(Boolean).join(path.delimiter); const installedHunk = path.join( installedBinDir, process.platform === "win32" ? "hunk.cmd" : "hunk", @@ -126,10 +129,7 @@ try { "bin", binaryFilenameForSpec(hostSpec), ); - const commandEnv = { - ...process.env, - PATH: sanitizedPath, - }; + const commandEnv = envWithPath(sanitizedPath); if (process.platform !== "win32") { const installedBinaryMode = statSync(installedPlatformBinary).mode & 0o777; diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts index 66989747..fb892b21 100644 --- a/scripts/stage-prebuilt-npm.ts +++ b/scripts/stage-prebuilt-npm.ts @@ -171,12 +171,13 @@ const artifactRoot = options.artifactRoot ? path.resolve(options.artifactRoot) : rmSync(releaseRoot, { recursive: true, force: true }); ensureDirectory(releaseRoot); +const hostSpec = artifactRoot ? undefined : getHostPlatformPackageSpec(); const artifacts = artifactRoot ? collectArtifactSpecs(artifactRoot) : [ { - spec: getHostPlatformPackageSpec(), - compiledBinary: path.join(repoRoot, "dist", "hunk"), + spec: hostSpec!, + compiledBinary: path.join(repoRoot, "dist", binaryFilenameForSpec(hostSpec!)), }, ]; @@ -195,5 +196,5 @@ for (const spec of stagedSpecs) { if (artifactRoot) { console.log(`Artifacts source: ${artifactRoot}`); } else { - console.log(`Artifacts source: ${path.join(repoRoot, "dist", "hunk")}`); + console.log(`Artifacts source: ${path.join(repoRoot, "dist", binaryFilenameForSpec(hostSpec!))}`); }