From c6eab459d725c467e749467e2c58a8e64fcd6675 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 01:13:07 +0000 Subject: [PATCH 1/7] feat(cli): add curl installer channel with checksum verification Ship a hosted install script (hunk.dev/install.sh) that resolves the platform archive from GitHub releases, verifies it against a new SHA256SUMS release asset, and lays the binary and bundled skills out under ~/.hunk so skill resolution keeps working. Teach the update seam a curl install source: detected from the .hunk/bin executable path, version-checked against GitHub releases, and updated by re-running the installer with the target version pinned, so curl installs get the same self-update behavior as npm and Homebrew instead of a manual download. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- .changeset/curl-install-channel.md | 5 + .github/workflows/release-prebuilt-npm.yml | 15 +- README.md | 8 +- scripts/install-sh.test.ts | 144 ++++++++ src/app/cli.ts | 18 +- src/core/process/installSource.test.ts | 45 +++ src/core/process/installSource.ts | 25 +- src/core/process/latestRelease.test.ts | 31 ++ src/core/process/latestRelease.ts | 41 ++- src/core/process/selfUpdate.test.ts | 77 +++- src/core/process/selfUpdate.ts | 118 +++++- src/core/process/updateNotice.test.ts | 43 +++ src/core/process/updateNotice.ts | 5 +- test/cli/update.test.ts | 15 +- vercel.json | 9 +- website/public/install.sh | 343 ++++++++++++++++++ .../src/content/docs/docs/reference/cli.md | 4 +- .../src/content/docs/docs/start/install.md | 37 +- 18 files changed, 931 insertions(+), 52 deletions(-) create mode 100644 .changeset/curl-install-channel.md create mode 100644 scripts/install-sh.test.ts create mode 100755 website/public/install.sh diff --git a/.changeset/curl-install-channel.md b/.changeset/curl-install-channel.md new file mode 100644 index 000000000..64c759b42 --- /dev/null +++ b/.changeset/curl-install-channel.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add a curl installer (`curl -fsSL https://hunk.dev/install.sh | sh`) with checksum verification, and teach `hunk update` to update curl installs. diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml index ee73810af..81474c646 100644 --- a/.github/workflows/release-prebuilt-npm.yml +++ b/.github/workflows/release-prebuilt-npm.yml @@ -324,9 +324,18 @@ jobs: done < <(find dist/release/artifacts -mindepth 1 -maxdepth 1 -type d -name 'hunkdiff-*' -print0 | sort -z) find dist/release/github -maxdepth 1 -type f | sort - # Attest the archives before they are uploaded so the provenance covers - # exactly the bytes published as release assets. The subject glob is kept - # identical to the upload glob below so nothing can ship unattested. + # One checksum manifest covering every archive, so `install.sh` can verify the + # download it just made. Names are written bare, without directories, so the file + # is checkable from whatever directory the installer extracts into. + - name: Write release checksums + run: | + cd dist/release/github + sha256sum hunkdiff-*.tar.gz > SHA256SUMS + cat SHA256SUMS + + # Attest the archives and their checksum manifest before they are uploaded so the + # provenance covers exactly the bytes published as release assets. The subject glob + # is kept identical to the upload glob below so nothing can ship unattested. # mise/aqua verifies these through GitHub Artifact Attestations on install. - name: Attest release archives uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 diff --git a/README.md b/README.md index 9f3cedab6..fe26a3302 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built npm i -g hunkdiff ``` +Or with the install script on macOS and Linux, which downloads the prebuilt binary, verifies its checksum, and installs into `~/.hunk`: + +```bash +curl -fsSL https://hunk.dev/install.sh | sh +``` + Or with Homebrew: ```bash @@ -53,7 +59,7 @@ mise use -g hunk Requirements: - macOS, Linux, or Windows -- Node.js 18+ for the npm install; Homebrew, mise, and Nix ship a standalone binary +- Node.js 18+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary - 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/scripts/install-sh.test.ts b/scripts/install-sh.test.ts new file mode 100644 index 000000000..68ad27ce1 --- /dev/null +++ b/scripts/install-sh.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { PLATFORM_PACKAGE_MATRIX } from "./prebuilt-package-helpers"; + +/** + * Covers `website/public/install.sh`, the script published at https://hunk.dev/install.sh. + * + * The script is the one piece of Hunk that runs before Hunk exists, so it is checked as text and + * as a shell program: it must parse under a POSIX shell, name the same release archives the + * publish workflow uploads, and resolve every published macOS/Linux platform pair. The platform + * checks source the script's own detection functions with `uname` stubbed, which needs a POSIX + * shell, so they stay Unix-only. + */ + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const INSTALL_SCRIPT_PATH = join(REPO_ROOT, "website", "public", "install.sh"); +const INSTALL_SCRIPT = readFileSync(INSTALL_SCRIPT_PATH, "utf8"); + +/** Platform pairs the installer serves: every published package except the Windows one. */ +const CURL_INSTALLABLE_SPECS = PLATFORM_PACKAGE_MATRIX.filter((spec) => spec.os !== "windows"); + +/** + * Run the installer's platform detection with a stubbed `uname` and print ` `. + * + * The script is sourced with its own body truncated at the detection call, so nothing downloads: + * the stub shadows `uname` (and `sysctl`) as shell functions, which take precedence over the real + * executables. + */ +function detectPlatform(unameSystem: string, unameMachine: string, translated = "0") { + const scriptDir = mkdtempSync(join(tmpdir(), "hunk-install-sh-")); + const harnessPath = join(scriptDir, "detect.sh"); + const [detectionBody] = INSTALL_SCRIPT.split('package_name="hunkdiff-'); + + try { + writeFileSync( + harnessPath, + [ + "#!/bin/sh", + "set -eu", + `uname() { if [ "\${1:-}" = "-m" ]; then printf '%s\\n' '${unameMachine}'; else printf '%s\\n' '${unameSystem}'; fi; }`, + `sysctl() { printf '%s\\n' '${translated}'; }`, + // The installer's own text, stopping just before it starts naming release archives. + detectionBody ?? "", + 'printf "%s %s\\n" "$os" "$arch"', + "", + ].join("\n"), + ); + + const result = Bun.spawnSync(["sh", harnessPath], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + return { + exitCode: result.exitCode, + stdout: Buffer.from(result.stdout).toString("utf8").trim(), + stderr: Buffer.from(result.stderr).toString("utf8").trim(), + }; + } finally { + rmSync(scriptDir, { recursive: true, force: true }); + } +} + +describe("hunk.dev install script", () => { + test("parses as a POSIX shell program", () => { + const result = Bun.spawnSync(["sh", "-n", INSTALL_SCRIPT_PATH], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + expect(Buffer.from(result.stderr).toString("utf8")).toBe(""); + expect(result.exitCode).toBe(0); + }); + + test("names the release assets the publish workflow uploads", () => { + expect(INSTALL_SCRIPT).toContain('archive_name="${package_name}.tar.gz"'); + expect(INSTALL_SCRIPT).toContain('package_name="hunkdiff-${os}-${arch}"'); + expect(INSTALL_SCRIPT).toContain("SHA256SUMS"); + expect(INSTALL_SCRIPT).toContain("https://github.com/${REPO}/releases/download"); + }); + + test("installs beside the bundled skills so skill resolution still finds them", () => { + // `resolveBundledSkillPath` walks up from the binary looking for `skills//SKILL.md`, + // so the payload directory must be the binary's directory or one of its ancestors. + expect(INSTALL_SCRIPT).toContain('bin_dir="${payload_dir}/bin"'); + expect(INSTALL_SCRIPT).toContain('mv "${temp_dir}/extract/skills" "${payload_dir}/skills"'); + expect(INSTALL_SCRIPT).toContain("--strip-components=1"); + }); + + test("points unsupported platforms at the npm package", () => { + expect(INSTALL_SCRIPT).toContain("npm install -g hunkdiff"); + }); + + test.skipIf(process.platform === "win32")( + "resolves every published macOS and Linux platform pair", + () => { + const detected = [ + { spec: "linux x64", ...detectPlatform("Linux", "x86_64") }, + { spec: "linux arm64", ...detectPlatform("Linux", "aarch64") }, + { spec: "darwin x64", ...detectPlatform("Darwin", "x86_64") }, + { spec: "darwin arm64", ...detectPlatform("Darwin", "arm64") }, + ]; + + expect(detected.map((entry) => `${entry.spec}: ${entry.stdout}`)).toEqual([ + "linux x64: linux x64", + "linux arm64: linux arm64", + "darwin x64: darwin x64", + "darwin arm64: darwin arm64", + ]); + // Every pair the installer resolves must be a package the release workflow publishes. + for (const entry of detected) { + const [os, arch] = entry.stdout.split(" "); + expect(CURL_INSTALLABLE_SPECS.some((spec) => spec.os === os && spec.cpu === arch)).toBe( + true, + ); + } + }, + ); + + test.skipIf(process.platform === "win32")( + "corrects a Rosetta-translated shell to the native arm64 build", + () => { + expect(detectPlatform("Darwin", "x86_64", "1").stdout).toBe("darwin arm64"); + }, + ); + + test.skipIf(process.platform === "win32")("rejects Windows-style uname output", () => { + const result = detectPlatform("MINGW64_NT-10.0-22631", "x86_64"); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("npm install -g hunkdiff"); + }); + + test.skipIf(process.platform === "win32")("rejects unsupported architectures", () => { + const result = detectPlatform("Linux", "riscv64"); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Unsupported architecture: riscv64"); + }); +}); diff --git a/src/app/cli.ts b/src/app/cli.ts index faaa493f4..2941a478d 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -243,7 +243,11 @@ export const CLI_REFERENCE_COMMANDS = { update: { path: "update", summary: "update Hunk with the package manager that installed it", - synopsis: ["hunk update [version]", "hunk update --check", "hunk update --method "], + synopsis: [ + "hunk update [version]", + "hunk update --check", + "hunk update --method ", + ], options: [ { flag: "--method ", @@ -1688,24 +1692,30 @@ async function parseUpdateCommand( [ command.helpInformation().trimEnd(), "", - "Hunk updates itself only for installs it owns: npm (or bun/pnpm global installs) and", - "Homebrew. Nix, mise, and local source builds print the command that updates them.", + "Hunk updates itself only for installs it owns: npm (or bun/pnpm global installs),", + "Homebrew, and installs from https://hunk.dev/install.sh. Nix, mise, and local source", + "builds print the command that updates them.", "", "Examples:", " hunk update", " hunk update 1.2.3", " hunk update --check", " hunk update --method brew", + " hunk update --method curl", ].join("\n") + "\n", }; } await parseStandaloneCommand(command, tokens); + // Validate the flag before the positional argument, so a typo in `--method` is reported as + // itself rather than shadowed by whatever version it was paired with. + const method = parsedOptions.method ? parseUpdateMethod(parsedOptions.method) : undefined; + return { kind: "update", version: parsedVersion === undefined ? undefined : parseUpdateVersion(parsedVersion), - method: parsedOptions.method ? parseUpdateMethod(parsedOptions.method) : undefined, + method, check: parsedOptions.check ?? false, }; } diff --git a/src/core/process/installSource.test.ts b/src/core/process/installSource.test.ts index f8a421f3d..b5f30f99f 100644 --- a/src/core/process/installSource.test.ts +++ b/src/core/process/installSource.test.ts @@ -99,6 +99,51 @@ describe("install source detection", () => { ).toBe("npm"); }); + test("detects curl installer installs from the ~/.hunk/bin layout", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: join(HOME_DIR, ".hunk", "bin", "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("curl"); + }); + + test("accepts curl as a declared install source", () => { + expect( + detectInstallSource({ + env: { HUNK_INSTALL_SOURCE: "curl" }, + executablePath: join("/", "opt", "hunk", "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("curl"); + }); + + test("keeps npm for a .hunk segment that is not followed by bin", () => { + expect( + detectInstallSource({ + env: {}, + executablePath: join(HOME_DIR, "projects", ".hunk", "review", "node_modules", "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("npm"); + }); + + test("classifies a curl install redirected by HUNK_INSTALL_DIR as a local source build", () => { + const installDir = join(HOME_DIR, "tools", "bin"); + expect( + detectInstallSource({ + env: { HUNK_INSTALL_DIR: installDir }, + executablePath: join(installDir, "hunk"), + version: "1.2.3", + homeDir: HOME_DIR, + }), + ).toBe("dev"); + }); + test("detects local source builds installed into the default install directory", () => { // Built from this platform's own default so the check tracks `scripts/install-bin.ts`. const installDir = resolveDevInstallDir({}, HOME_DIR); diff --git a/src/core/process/installSource.ts b/src/core/process/installSource.ts index de830ce14..8c30630f9 100644 --- a/src/core/process/installSource.ts +++ b/src/core/process/installSource.ts @@ -17,12 +17,12 @@ const INSTALL_DIR_ENV = "HUNK_INSTALL_DIR"; /** Path segments Homebrew always puts above its binaries, on macOS and Linux alike. */ const HOMEBREW_PATH_SEGMENTS = ["cellar", "homebrew", "linuxbrew"]; -export type InstallSource = "npm" | "homebrew" | "nix" | "mise" | "dev"; +export type InstallSource = "npm" | "homebrew" | "nix" | "mise" | "curl" | "dev"; /** Package-manager clients that can install the global `hunkdiff` npm package. */ export type NpmClient = "npm" | "bun" | "pnpm"; -const INSTALL_SOURCES: readonly InstallSource[] = ["npm", "homebrew", "nix", "mise", "dev"]; +const INSTALL_SOURCES: readonly InstallSource[] = ["npm", "homebrew", "nix", "mise", "curl", "dev"]; export interface InstallSourceFacts { env?: NodeJS.ProcessEnv; @@ -70,6 +70,19 @@ function isMiseManagedExecutablePath(executablePath: string) { ); } +/** + * Return whether this executable came from the `hunk.dev/install.sh` curl installer. + * + * The installer owns a single tree — `~/.hunk`, with the binary at `~/.hunk/bin/hunk` and the + * bundled skills beside it — and writes no environment variable, so the adjacent `.hunk`/`bin` + * segments are the only signal, read the same way mise's layout is. Adjacency matters: a checkout + * that keeps review artifacts in a repo-local `.hunk/` directory is not a curl install. + */ +function isCurlInstalledExecutablePath(executablePath: string) { + const segments = splitPathSegments(executablePath); + return segments.some((segment, index) => segment === ".hunk" && segments[index + 1] === "bin"); +} + /** Executable names the Homebrew formula installs, lowercased and without a Windows suffix. */ const HOMEBREW_ARTIFACT_NAMES = ["hunk", "hunkdiff"]; @@ -178,6 +191,14 @@ export function detectInstallSource(facts: InstallSourceFacts = {}): InstallSour return "homebrew"; } + if (isCurlInstalledExecutablePath(executablePath)) { + return "curl"; + } + + // Boundary: a curl install redirected elsewhere with `HUNK_INSTALL_DIR` classifies as `dev`, + // because that variable already names the directory `bun run install:bin` writes to. The two + // channels are indistinguishable once they share a directory the user chose, and `dev` is the + // safe answer — it prints the command to rerun instead of replacing a binary Hunk does not own. const homeDir = facts.homeDir ?? env.HOME ?? env.USERPROFILE; if (isInsideDirectory(executablePath, resolveDevInstallDir(env, homeDir))) { return "dev"; diff --git a/src/core/process/latestRelease.test.ts b/src/core/process/latestRelease.test.ts index 16a52a4e8..d87124352 100644 --- a/src/core/process/latestRelease.test.ts +++ b/src/core/process/latestRelease.test.ts @@ -38,6 +38,37 @@ describe("release channel lookups", () => { expect(requested).toEqual(["https://formulae.brew.sh/api/formula/hunk.json"]); }); + test("reads the newest GitHub release tag for curl installer installs", async () => { + const requested: string[] = []; + const accepts: unknown[] = []; + + await expect( + fetchChannelVersions("curl", { + fetchImpl: async (input, init) => { + requested.push(String(input)); + accepts.push(new Headers(init?.headers).get("accept")); + return jsonResponse({ tag_name: "v1.4.0" }); + }, + }), + ).resolves.toEqual({ latest: "1.4.0" }); + expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]); + expect(accepts).toEqual(["application/vnd.github+json"]); + }); + + test("drops a GitHub release tag that is not a stable version", async () => { + await expect( + fetchChannelVersions("curl", { + fetchImpl: async () => jsonResponse({ tag_name: "v1.4.0-beta.1" }), + }), + ).resolves.toEqual({ latest: undefined }); + + await expect( + fetchChannelVersions("curl", { + fetchImpl: async () => jsonResponse({ name: "1.4.0" }), + }), + ).resolves.toEqual({ latest: undefined }); + }); + test("asks no registry for install sources Hunk cannot update", async () => { for (const source of ["nix", "mise", "dev"] as const) { await expect( diff --git a/src/core/process/latestRelease.ts b/src/core/process/latestRelease.ts index c02b2a35c..74d93a09a 100644 --- a/src/core/process/latestRelease.ts +++ b/src/core/process/latestRelease.ts @@ -5,13 +5,15 @@ import { isPrereleaseVersion, isStableVersion } from "../run/version"; * Fetches the versions each install channel publishes for Hunk. * * One lookup per channel, asked of the registry that channel actually installs from: npm reads the - * `hunkdiff` dist-tags, Homebrew reads its formula API. Channels Hunk cannot update through — Nix, - * mise, and local source builds — report nothing rather than borrowing another channel's numbers, - * which is what made Homebrew users see releases `brew` could not yet install. + * `hunkdiff` dist-tags, Homebrew reads its formula API, and the curl installer reads the GitHub + * release the archives hang off. Channels Hunk cannot update through — Nix, mise, and local source + * builds — report nothing rather than borrowing another channel's numbers, which is what made + * Homebrew users see releases `brew` could not yet install. */ const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/hunkdiff/dist-tags"; const HOMEBREW_FORMULA_URL = "https://formulae.brew.sh/api/formula/hunk.json"; +const GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/modem-dev/hunk/releases/latest"; const DEFAULT_RELEASE_FETCH_TIMEOUT_MS = 5_000; export type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -49,14 +51,18 @@ function createFetchTimeoutSignal(timeoutMs: number) { } /** Fetch and parse one JSON document, returning null for any failure or timeout. */ -async function fetchJson(url: string, deps: ReleaseLookupDeps): Promise { +async function fetchJson( + url: string, + deps: ReleaseLookupDeps, + headers?: Record, +): Promise { const fetchImpl = deps.fetchImpl ?? fetch; const { signal, dispose } = createFetchTimeoutSignal( deps.fetchTimeoutMs ?? DEFAULT_RELEASE_FETCH_TIMEOUT_MS, ); try { - const response = await fetchImpl(url, { signal }); + const response = await fetchImpl(url, { signal, headers }); if (!response.ok) { return null; } @@ -111,9 +117,28 @@ export async function fetchHomebrewChannelVersions( return { latest: stable && isStableVersion(stable) ? stable : undefined }; } +/** + * Fetch the version of the newest GitHub release the curl installer downloads from. + * + * `releases/latest` never points at a prerelease, so a curl install only ever hears about + * `latest`. Release tags are spelled `v1.2.3` and versions are not, so the prefix is stripped + * before validation. + */ +export async function fetchCurlChannelVersions( + deps: ReleaseLookupDeps = {}, +): Promise { + const payload = await fetchJson(GITHUB_LATEST_RELEASE_URL, deps, { + Accept: "application/vnd.github+json", + }); + const tagName = readStringField(payload, "tag_name"); + const version = tagName?.startsWith("v") ? tagName.slice(1) : tagName; + + return { latest: version && isStableVersion(version) ? version : undefined }; +} + /** Return whether an install source can be updated from a published release at all. */ export function hasPublishedReleases(installSource: InstallSource) { - return installSource === "npm" || installSource === "homebrew"; + return installSource === "npm" || installSource === "homebrew" || installSource === "curl"; } /** Fetch the versions one install source publishes, asking that channel's own registry. */ @@ -125,6 +150,10 @@ export async function fetchChannelVersions( return fetchHomebrewChannelVersions(deps); } + if (installSource === "curl") { + return fetchCurlChannelVersions(deps); + } + if (installSource === "npm") { return fetchNpmChannelVersions(deps); } diff --git a/src/core/process/selfUpdate.test.ts b/src/core/process/selfUpdate.test.ts index 2efd96bd5..0424647a7 100644 --- a/src/core/process/selfUpdate.test.ts +++ b/src/core/process/selfUpdate.test.ts @@ -7,6 +7,7 @@ import { runSelfUpdateCommand, type SelfUpdateInput, type SelfUpdateProcessResult, + UPDATE_METHOD_VALUES, } from "./selfUpdate"; /** Build one JSON response for an injected fetch. */ @@ -24,6 +25,7 @@ interface UpdateRunOptions { executablePath?: string; platform?: NodeJS.Platform; latestVersion?: string; + env?: NodeJS.ProcessEnv; commandResult?: SelfUpdateProcessResult; } @@ -32,31 +34,41 @@ async function runUpdate(options: UpdateRunOptions) { const stdout: string[] = []; const stderr: string[] = []; const commands: string[][] = []; + const commandEnvs: Array = []; + const latestVersion = options.latestVersion ?? "1.1.0"; const exitCode = await runSelfUpdateCommand( { check: false, ...options.input }, { stdout: (text) => stdout.push(text), stderr: (text) => stderr.push(text), - env: {}, + env: options.env ?? {}, executablePath: options.executablePath ?? join("/", "usr", "bin", "hunk"), platform: options.platform ?? "linux", resolveInstalledVersion: () => options.installedVersion ?? "1.0.0", resolveInstallSource: () => options.installSource, - // One payload carrying both registry shapes, so a `--method` override still resolves. + // One payload carrying every registry shape, so a `--method` override still resolves. fetchImpl: async () => jsonResponse({ - latest: options.latestVersion ?? "1.1.0", - versions: { stable: options.latestVersion ?? "1.1.0" }, + latest: latestVersion, + versions: { stable: latestVersion }, + tag_name: `v${latestVersion}`, }), - runCommand: async (command) => { + runCommand: async (command, commandOptions) => { commands.push([...command]); + commandEnvs.push(commandOptions?.env); return options.commandResult ?? { exitCode: 0, stderr: "" }; }, }, ); - return { exitCode, stdout: stdout.join(""), stderr: stderr.join(""), commands }; + return { + exitCode, + stdout: stdout.join(""), + stderr: stderr.join(""), + commands, + commandEnvs, + }; } describe("update method parsing", () => { @@ -66,8 +78,23 @@ describe("update method parsing", () => { expect(parseUpdateMethod("npm")).toBe("npm"); }); + test("maps curl to the install-script source", () => { + expect(parseUpdateMethod("curl")).toBe("curl"); + expect(parseUpdateMethod("CURL")).toBe("curl"); + }); + test("names the supported methods for unknown values", () => { expect(() => parseUpdateMethod("apt")).toThrow("Unknown update method: apt"); + expect(UPDATE_METHOD_VALUES).toEqual(["npm", "brew", "curl"]); + + try { + parseUpdateMethod("apt"); + throw new Error("parseUpdateMethod should have rejected an unknown method"); + } catch (error) { + expect((error as { suggestions?: string[] }).suggestions).toEqual([ + "Supported methods are `npm`, `brew`, and `curl`.", + ]); + } }); }); @@ -156,6 +183,44 @@ describe("hunk update", () => { expect(result.commands).toEqual([["brew", "upgrade", "hunk"]]); }); + test("re-runs the install script for curl installs", async () => { + const result = await runUpdate({ + installSource: "curl", + latestVersion: "1.1.0", + env: { PATH: "/usr/bin", HOME: "/home/reviewer" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([["sh", "-c", "curl -fsSL https://hunk.dev/install.sh | sh"]]); + // The installer resolves the version from its environment, so the child carries the target + // alongside the rest of this process's environment. + expect(result.commandEnvs).toEqual([ + { PATH: "/usr/bin", HOME: "/home/reviewer", HUNK_VERSION: "1.1.0" }, + ]); + expect(result.stdout).toContain("Updated hunk to 1.1.0."); + }); + + test("pins an explicitly requested version for curl installs", async () => { + const result = await runUpdate({ + installSource: "curl", + installedVersion: "1.1.0", + input: { version: "0.9.0" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([["sh", "-c", "curl -fsSL https://hunk.dev/install.sh | sh"]]); + expect(result.commandEnvs[0]?.HUNK_VERSION).toBe("0.9.0"); + }); + + test("reports the GitHub release version for a curl --check", async () => { + const result = await runUpdate({ installSource: "curl", input: { check: true } }); + + expect(result.exitCode).toBe(0); + expect(result.commands).toEqual([]); + expect(result.stdout).toContain("hunk 1.0.0 (installed with the install script)"); + expect(result.stdout).toContain("latest 1.1.0"); + }); + test("refuses to pin a version on Homebrew", async () => { await expect( runUpdate({ installSource: "homebrew", input: { version: "1.0.5" } }), diff --git a/src/core/process/selfUpdate.ts b/src/core/process/selfUpdate.ts index 194398c0e..22fc8fc5e 100644 --- a/src/core/process/selfUpdate.ts +++ b/src/core/process/selfUpdate.ts @@ -6,25 +6,41 @@ import { isComparableVersion, isNewerVersion, resolveCliVersion } from "../run/v /** * Runs `hunk update`: replaces this Hunk install with a published release, or explains who can. * - * The install source decides everything. npm and Homebrew installs are replaced in place by - * spawning the package manager that owns them; Nix, mise, and local source builds are owned by - * something Hunk must not run behind the user's back, so those print the one command that does - * work and stop. Every input the command reads or writes — environment, executable path, network, - * child processes, output streams — arrives through `SelfUpdateIo` so tests drive it offline. + * The install source decides everything. npm, Homebrew, and curl-installer installs are replaced in + * place by re-running whatever owns them; Nix, mise, and local source builds are owned by something + * Hunk must not run behind the user's back, so those print the one command that does work and stop. + * Every input the command reads or writes — environment, executable path, network, child processes, + * output streams — arrives through `SelfUpdateIo` so tests drive it offline. */ const NPM_PACKAGE_NAME = "hunkdiff"; const HOMEBREW_FORMULA_NAME = "hunk"; +const CURL_INSTALL_SCRIPT_URL = "https://hunk.dev/install.sh"; +const CURL_INSTALL_VERSION_ENV = "HUNK_VERSION"; + +/** Install sources `hunk update` can replace on its own. */ +const SELF_UPDATABLE_SOURCES: readonly InstallSource[] = ["npm", "homebrew", "curl"]; /** Install methods `--method` accepts, keyed by the spelling users type. */ const UPDATE_METHOD_ALIASES: Record = { npm: "npm", brew: "homebrew", homebrew: "homebrew", + curl: "curl", }; /** Accepted `--method` values, in the order the help and error messages list them. */ -export const UPDATE_METHOD_VALUES = ["npm", "brew"] as const; +export const UPDATE_METHOD_VALUES = ["npm", "brew", "curl"] as const; + +/** Join accepted values into the "`a`, `b`, and `c`" phrasing the error messages use. */ +function listUpdateMethods() { + const quoted = UPDATE_METHOD_VALUES.map((name) => `\`${name}\``); + if (quoted.length < 3) { + return quoted.join(" and "); + } + + return `${quoted.slice(0, -1).join(", ")}, and ${quoted.at(-1)}`; +} export interface SelfUpdateInput { /** Version to install; the channel's newest release when omitted. */ @@ -41,6 +57,12 @@ export interface SelfUpdateProcessResult { stderr: string; } +/** Extra spawn settings one update command needs beyond its argv. */ +export interface SelfUpdateCommandOptions { + /** Full environment for the child; the parent's own environment when omitted. */ + env?: NodeJS.ProcessEnv; +} + export interface SelfUpdateIo { stdout: (text: string) => void; stderr: (text: string) => void; @@ -52,7 +74,10 @@ export interface SelfUpdateIo { resolveInstallSource?: () => InstallSource; fetchImpl?: FetchImpl; fetchTimeoutMs?: number; - runCommand?: (command: readonly string[]) => Promise; + runCommand?: ( + command: readonly string[], + options?: SelfUpdateCommandOptions, + ) => Promise; } /** Normalize one `--method` value, or explain which values exist. */ @@ -60,7 +85,7 @@ export function parseUpdateMethod(value: string): InstallSource { const method = UPDATE_METHOD_ALIASES[value.toLowerCase()]; if (!method) { throw new HunkUserError(`Unknown update method: ${value}`, [ - `Supported methods are ${UPDATE_METHOD_VALUES.map((name) => `\`${name}\``).join(" and ")}.`, + `Supported methods are ${listUpdateMethods()}.`, ]); } @@ -94,6 +119,10 @@ function describeInstallSource(installSource: InstallSource) { return "a local source build"; } + if (installSource === "curl") { + return "the install script"; + } + return installSource; } @@ -119,12 +148,47 @@ function npmUpdateCommand( return [shim("npm"), "install", "--global", spec]; } +/** + * Build the command that re-runs the curl installer for one target version. + * + * The installer is the updater: it already resolves the platform archive, verifies its checksum, + * and swaps the tree in place, so duplicating any of that here would give curl installs a second + * download path that can drift from the one users pipe into `sh`. The target version travels in + * the child environment rather than the command line so the pipeline stays byte-identical to the + * documented one-liner. + */ +function curlUpdateCommand() { + return ["sh", "-c", `curl -fsSL ${CURL_INSTALL_SCRIPT_URL} | sh`]; +} + +/** Choose the command that installs one target version for the channel that owns this binary. */ +function buildUpdateCommand( + installSource: InstallSource, + executablePath: string, + targetVersion: string, + platform: NodeJS.Platform, +) { + if (installSource === "homebrew") { + return ["brew", "upgrade", HOMEBREW_FORMULA_NAME]; + } + + if (installSource === "curl") { + return curlUpdateCommand(); + } + + return npmUpdateCommand(executablePath, targetVersion, platform); +} + /** Spawn one package-manager command, streaming its output and capturing stderr for failures. */ -async function spawnUpdateCommand(command: readonly string[]): Promise { +async function spawnUpdateCommand( + command: readonly string[], + options: SelfUpdateCommandOptions = {}, +): Promise { const [executable] = command; try { const child = Bun.spawn({ cmd: [...command], + env: options.env, stdin: "ignore", stdout: "inherit", stderr: "pipe", @@ -140,6 +204,19 @@ async function spawnUpdateCommand(command: readonly string[]): Promise detectInstallSource({ env, executablePath, version: installedVersion })) )(); - if (installSource !== "npm" && installSource !== "homebrew") { + if (!SELF_UPDATABLE_SOURCES.includes(installSource)) { if (input.version) { throw new HunkUserError( `Hunk installed with ${describeInstallSource(installSource)} cannot update to a specific version from here.`, @@ -218,10 +295,7 @@ export async function runSelfUpdateCommand( }); const latestVersion = channelVersions.latest; const targetVersion = input.version ?? latestVersion; - const fetchFailedMessage = - installSource === "homebrew" - ? "Could not read the latest Hunk version from the Homebrew formula API." - : "Could not read the latest Hunk version from the npm registry."; + const fetchFailedMessage = describeFetchFailure(installSource); // `--check` always reports against the channel's real latest release; a requested version is // named separately so it is never mislabeled as "latest". @@ -261,17 +335,23 @@ export async function runSelfUpdateCommand( return 0; } - const command = - installSource === "homebrew" - ? ["brew", "upgrade", HOMEBREW_FORMULA_NAME] - : npmUpdateCommand(executablePath, targetVersion, io.platform ?? process.platform); + const command = buildUpdateCommand( + installSource, + executablePath, + targetVersion, + io.platform ?? process.platform, + ); + // Only the curl installer reads a version from its environment; every other channel names the + // target in its argv, so the child otherwise inherits this process's environment untouched. + const commandOptions: SelfUpdateCommandOptions = + installSource === "curl" ? { env: { ...env, [CURL_INSTALL_VERSION_ENV]: targetVersion } } : {}; io.stdout( `Updating hunk ${installedVersion} -> ${targetVersion} with \`${command.join(" ")}\`\n`, ); const runCommand = io.runCommand ?? spawnUpdateCommand; - const result = await runCommand(command); + const result = await runCommand(command, commandOptions); if (result.exitCode !== 0) { const details = result.stderr.trim(); if (details.length > 0) { diff --git a/src/core/process/updateNotice.test.ts b/src/core/process/updateNotice.test.ts index 546f1f4b3..5e168c1ac 100644 --- a/src/core/process/updateNotice.test.ts +++ b/src/core/process/updateNotice.test.ts @@ -20,6 +20,14 @@ function createFormulaResponse(stable: string) { }); } +/** Build one JSON response that mimics the GitHub latest-release payload. */ +function createGitHubReleaseResponse(tagName: string) { + return new Response(JSON.stringify({ tag_name: tagName }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + /** Executable path of a plain global npm install, pinned so detection never reads the host. */ const NPM_EXECUTABLE_PATH = join("/", "usr", "lib", "node_modules", "hunkdiff", "bin", "hunk"); @@ -118,6 +126,41 @@ describe("startup update notice", () => { }); }); + test("reads the GitHub releases API for curl installer installs", async () => { + await withTempStatePath(async (statePath) => { + const requested: string[] = []; + + await expect( + resolveStartupUpdateNotice({ + fetchImpl: async (input) => { + requested.push(String(input)); + return createGitHubReleaseResponse("v0.7.1"); + }, + resolveExecutablePath: () => join("/", "home", "reviewer", ".hunk", "bin", "hunk"), + resolveInstalledVersion: () => "0.7.0", + statePath, + }), + ).resolves.toEqual({ + key: "latest:0.7.1", + message: "Update available: 0.7.1 (latest) • run `hunk update`", + }); + expect(requested).toEqual(["https://api.github.com/repos/modem-dev/hunk/releases/latest"]); + }); + }); + + test("stays quiet for curl installs already on the newest release", async () => { + await withTempStatePath(async (statePath) => { + await expect( + resolveStartupUpdateNotice({ + fetchImpl: async () => createGitHubReleaseResponse("v0.7.0"), + resolveInstallSource: () => "curl", + resolveInstalledVersion: () => "0.7.0", + statePath, + }), + ).resolves.toBeNull(); + }); + }); + test("detects Homebrew installs from the HUNK_INSTALL_SOURCE environment variable", async () => { await withTempStatePath(async (statePath) => { await expect( diff --git a/src/core/process/updateNotice.ts b/src/core/process/updateNotice.ts index ee4cb4d2d..6e4f2fcca 100644 --- a/src/core/process/updateNotice.ts +++ b/src/core/process/updateNotice.ts @@ -56,8 +56,9 @@ function suppressesNotices(installSource: InstallSource) { * Build the install-aware update instruction shown for one release channel. * * Sources with no notice at all never reach here; they are filtered out before the release lookup. - * npm and Homebrew installs both update in place through `hunk update`, so the notice names that - * one command; a beta build names the version because `hunk update` alone tracks `latest`. + * npm, Homebrew, and curl-installer installs all update in place through `hunk update`, so the + * notice names that one command; a beta build names the version because `hunk update` alone + * tracks `latest`. */ function updateInstructionForChannel( channel: UpdateChannel, diff --git a/test/cli/update.test.ts b/test/cli/update.test.ts index 3fc53cadc..8d2cd7938 100644 --- a/test/cli/update.test.ts +++ b/test/cli/update.test.ts @@ -49,9 +49,10 @@ describe("hunk update CLI contract", () => { expect(result.stderr).toBe(""); expect(result.stdout).toContain("Usage: update [options] [version]"); expect(result.stdout).toContain("--method "); - expect(result.stdout).toContain("npm, brew"); + expect(result.stdout).toContain("npm, brew, curl"); expect(result.stdout).toContain("--check"); expect(result.stdout).toContain("hunk update --method brew"); + expect(result.stdout).toContain("hunk update --method curl"); expect(result.stdout).not.toContain("[?1049h"); }); @@ -93,7 +94,17 @@ describe("hunk update CLI contract", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Unknown update method: apt"); - expect(result.stderr).toContain("Supported methods are `npm` and `brew`."); + expect(result.stderr).toContain("Supported methods are `npm`, `brew`, and `curl`."); + }); + + test("accepts curl as an explicit update method", () => { + // `--method curl` with an unresolvable version reaches argument validation and stops there, + // so the contract is checked without a release lookup or an install. + const result = runUpdate(["--method", "curl", "not-a-version"], "dev"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid version: not-a-version"); + expect(result.stderr).not.toContain("Unknown update method"); }); test("rejects unknown update flags", () => { diff --git a/vercel.json b/vercel.json index c6a521b56..ac1dbb069 100644 --- a/vercel.json +++ b/vercel.json @@ -4,5 +4,12 @@ "ignoreCommand": "git diff --quiet HEAD^ HEAD -- ./website ./vercel.json ./scripts/generate-docs.ts ./scripts/generate-changelog.ts ./CHANGELOG.md", "installCommand": "SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile && bun install --cwd website --frozen-lockfile", "buildCommand": "bun run website:build", - "outputDirectory": "website/dist" + "outputDirectory": "website/dist", + "rewrites": [{ "source": "/install", "destination": "/install.sh" }], + "headers": [ + { + "source": "/install(.sh)?", + "headers": [{ "key": "Content-Type", "value": "text/plain; charset=utf-8" }] + } + ] } diff --git a/website/public/install.sh b/website/public/install.sh new file mode 100755 index 000000000..31e01db62 --- /dev/null +++ b/website/public/install.sh @@ -0,0 +1,343 @@ +#!/bin/sh +# +# Hunk installer — https://hunk.dev +# +# Downloads the prebuilt Hunk release archive for this machine, verifies it against the +# release's SHA256SUMS, and installs it into ~/.hunk (binary at ~/.hunk/bin/hunk, bundled +# agent skills beside it at ~/.hunk/skills, which is where `hunk skill path` looks). +# +# Usage: +# curl -fsSL https://hunk.dev/install.sh | sh +# curl -fsSL https://hunk.dev/install.sh | sh -s -- 0.19.0 +# curl -fsSL https://hunk.dev/install.sh | sh -s -- --no-modify-path +# +# Environment: +# HUNK_VERSION version to install (default: the newest GitHub release) +# HUNK_INSTALL_DIR directory to install the binary into (default: $HOME/.hunk/bin) +# HUNK_NO_MODIFY_PATH set to 1 to leave shell startup files alone +# +# macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. + +set -eu + +REPO="modem-dev/hunk" +RELEASES_API="https://api.github.com/repos/${REPO}/releases/latest" +DOWNLOAD_BASE="https://github.com/${REPO}/releases/download" + +# -------------------------------------------------------------------------------------- +# Output helpers +# -------------------------------------------------------------------------------------- + +info() { + printf '%s\n' "$1" +} + +warn() { + printf 'warning: %s\n' "$1" >&2 +} + +fail() { + printf 'error: %s\n' "$1" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Install Hunk, the terminal diff viewer. + +Usage: + install.sh [version] [options] + +Arguments: + version release to install, for example 0.19.0 (default: newest release) + +Options: + --no-modify-path do not add the install directory to your shell startup files + -h, --help show this help + +Environment: + HUNK_VERSION same as the positional version argument + HUNK_INSTALL_DIR directory to install the binary into (default: $HOME/.hunk/bin) + HUNK_NO_MODIFY_PATH set to 1 for --no-modify-path + +macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. +EOF +} + +# -------------------------------------------------------------------------------------- +# Arguments +# -------------------------------------------------------------------------------------- + +version="${HUNK_VERSION:-}" +no_modify_path="${HUNK_NO_MODIFY_PATH:-0}" + +while [ "$#" -gt 0 ]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --no-modify-path) + no_modify_path=1 + ;; + -*) + fail "Unknown option: $1 (run with --help to see the supported options)" + ;; + *) + version="$1" + ;; + esac + shift +done + +# Release tags are spelled `v1.2.3`; asset names and `--version` output are not. +version="${version#v}" + +# -------------------------------------------------------------------------------------- +# Platform detection +# -------------------------------------------------------------------------------------- + +# Print the release archive's OS token, or fail with the npm fallback for anything unsupported. +detect_os() { + os="$(uname -s)" + case "$os" in + Darwin) printf 'darwin\n' ;; + Linux) printf 'linux\n' ;; + *) + fail "Unsupported operating system: ${os}. Install Hunk with \`npm install -g hunkdiff\` instead." + ;; + esac +} + +# Print the release archive's CPU token for this machine. +# +# On Apple silicon a Rosetta-translated shell reports x86_64 even though the native binary is +# the arm64 one, so `sysctl.proc_translated` corrects the answer back to the real hardware. +detect_arch() { + arch="$(uname -m)" + case "$arch" in + x86_64 | amd64) + if [ "$(uname -s)" = "Darwin" ] && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then + printf 'arm64\n' + else + printf 'x64\n' + fi + ;; + arm64 | aarch64) + printf 'arm64\n' + ;; + *) + fail "Unsupported architecture: ${arch}. Install Hunk with \`npm install -g hunkdiff\` instead." + ;; + esac +} + +os="$(detect_os)" +arch="$(detect_arch)" +package_name="hunkdiff-${os}-${arch}" +archive_name="${package_name}.tar.gz" + +# -------------------------------------------------------------------------------------- +# Downloading +# -------------------------------------------------------------------------------------- + +if command -v curl >/dev/null 2>&1; then + downloader="curl" +elif command -v wget >/dev/null 2>&1; then + downloader="wget" +else + fail "Neither curl nor wget is available. Install one of them and try again." +fi + +# Download one URL to one path, returning non-zero when the server refuses it. +download() { + if [ "$downloader" = "curl" ]; then + curl -fsSL "$1" -o "$2" + else + wget -q -O "$2" "$1" + fi +} + +# Print one URL's body, returning non-zero when the server refuses it. +fetch() { + if [ "$downloader" = "curl" ]; then + curl -fsSL "$1" + else + wget -q -O - "$1" + fi +} + +# -------------------------------------------------------------------------------------- +# Version resolution +# -------------------------------------------------------------------------------------- + +if [ -z "$version" ]; then + info "Resolving the newest Hunk release..." + # Parsed with sed rather than jq so the installer needs nothing but a shell and a downloader. + version="$(fetch "$RELEASES_API" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)" + [ -n "$version" ] || fail "Could not resolve the newest Hunk release from ${RELEASES_API}." +fi + +# -------------------------------------------------------------------------------------- +# Install layout +# -------------------------------------------------------------------------------------- + +home_dir="${HOME:-}" +[ -n "$home_dir" ] || fail "HOME is not set, so there is nowhere to install Hunk." + +if [ -n "${HUNK_INSTALL_DIR:-}" ]; then + bin_dir="$HUNK_INSTALL_DIR" + # The bundled skills are found by walking up from the binary, so a chosen directory holds + # both. `hunk update` reports this install as a local build rather than a curl install, + # because HUNK_INSTALL_DIR is also where `bun run install:bin` writes. + payload_dir="$HUNK_INSTALL_DIR" +else + payload_dir="${home_dir}/.hunk" + bin_dir="${payload_dir}/bin" +fi + +target_binary="${bin_dir}/hunk" + +# -------------------------------------------------------------------------------------- +# Already-current check +# -------------------------------------------------------------------------------------- + +# Print one Hunk binary's version, or nothing when it cannot run. +installed_version() { + [ -x "$1" ] || return 0 + "$1" --version 2>/dev/null | tr -d 'v \t\r' | head -n 1 +} + +current="$(installed_version "$target_binary")" +if [ -z "$current" ] && command -v hunk >/dev/null 2>&1; then + current="$(installed_version "$(command -v hunk)")" +fi + +if [ "$current" = "$version" ]; then + info "hunk ${version} is already installed." + exit 0 +fi + +# -------------------------------------------------------------------------------------- +# Download, verify, install +# -------------------------------------------------------------------------------------- + +temp_dir="$(mktemp -d)" +cleanup() { + rm -rf "$temp_dir" +} +trap cleanup EXIT INT TERM + +archive_url="${DOWNLOAD_BASE}/v${version}/${archive_name}" +info "Downloading ${archive_name} (v${version})..." +download "$archive_url" "${temp_dir}/${archive_name}" || + fail "Could not download ${archive_url}. Check that the version exists and that this platform is published." + +# Checksums ship as one SHA256SUMS asset covering every archive in the release. Releases made +# before that asset existed still install, with a warning rather than a silent skip. +if download "${DOWNLOAD_BASE}/v${version}/SHA256SUMS" "${temp_dir}/SHA256SUMS" 2>/dev/null; then + if command -v sha256sum >/dev/null 2>&1; then + checksum_tool="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + checksum_tool="shasum -a 256" + else + checksum_tool="" + fi + + if [ -n "$checksum_tool" ]; then + grep " \{1,2\}${archive_name}\$" "${temp_dir}/SHA256SUMS" >"${temp_dir}/SHA256SUMS.one" || + fail "SHA256SUMS has no entry for ${archive_name}. Refusing to install an unverified archive." + info "Verifying checksum..." + (cd "$temp_dir" && $checksum_tool -c SHA256SUMS.one >/dev/null) || + fail "Checksum verification failed for ${archive_name}. Refusing to install a corrupted or tampered archive." + else + warn "Neither sha256sum nor shasum is available, so the archive checksum was not verified." + fi +else + warn "This release publishes no SHA256SUMS asset, so the archive checksum was not verified." +fi + +info "Installing to ${bin_dir}..." +mkdir -p "${temp_dir}/extract" +# The archive holds one top-level `hunkdiff--/` directory with the binary, the bundled +# skills, and metadata.json inside it; stripping that wrapper puts the payload at the root. +tar -xzf "${temp_dir}/${archive_name}" -C "${temp_dir}/extract" --strip-components=1 +[ -f "${temp_dir}/extract/hunk" ] || fail "The downloaded archive contains no hunk binary." +chmod 0755 "${temp_dir}/extract/hunk" + +mkdir -p "$payload_dir" "$bin_dir" +# Replace the skills and metadata first, then move the binary last and through a same-directory +# rename, so a Hunk that is running right now is never left pointing at a half-written tree. +rm -rf "${payload_dir}/skills" +mv "${temp_dir}/extract/skills" "${payload_dir}/skills" +if [ -f "${temp_dir}/extract/metadata.json" ]; then + mv -f "${temp_dir}/extract/metadata.json" "${payload_dir}/metadata.json" +fi +mv -f "${temp_dir}/extract/hunk" "${bin_dir}/hunk.new" +mv -f "${bin_dir}/hunk.new" "$target_binary" +chmod 0755 "$target_binary" + +info "Installed hunk ${version} to ${target_binary}" + +# -------------------------------------------------------------------------------------- +# PATH +# -------------------------------------------------------------------------------------- + +path_line="export PATH=\"${bin_dir}:\$PATH\"" + +# Append one line to one file unless an equivalent line is already there. Prints what it did. +add_path_line() { + rc_file="$1" + line="$2" + if [ -f "$rc_file" ] && grep -Fq "$bin_dir" "$rc_file"; then + info "${rc_file} already puts ${bin_dir} on PATH." + return 0 + fi + + mkdir -p "$(dirname "$rc_file")" + printf '\n# Added by the Hunk installer (https://hunk.dev)\n%s\n' "$line" >>"$rc_file" + info "Added ${bin_dir} to PATH in ${rc_file}." +} + +# Print the first of the given candidate startup files that exists, or the first candidate. +first_existing() { + fallback="$1" + for candidate in "$@"; do + if [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + printf '%s\n' "$fallback" +} + +if [ "$no_modify_path" = "1" ]; then + info "Left shell startup files untouched (--no-modify-path)." + info "Add ${bin_dir} to your PATH to run hunk from anywhere." +elif [ -n "${GITHUB_PATH:-}" ]; then + # GitHub Actions reads this file between steps, so no shell startup file is involved. + printf '%s\n' "$bin_dir" >>"$GITHUB_PATH" + info "Added ${bin_dir} to \$GITHUB_PATH for later workflow steps." +else + shell_name="$(basename "${SHELL:-sh}")" + case "$shell_name" in + zsh) + add_path_line "${ZDOTDIR:-$home_dir}/.zshrc" "$path_line" + ;; + bash) + add_path_line \ + "$(first_existing "${home_dir}/.bashrc" "${home_dir}/.bash_profile" "${home_dir}/.profile")" \ + "$path_line" + ;; + fish) + add_path_line "${home_dir}/.config/fish/config.fish" "fish_add_path \"${bin_dir}\"" + ;; + *) + add_path_line "${home_dir}/.profile" "$path_line" + ;; + esac + info "Restart your shell, or run: export PATH=\"${bin_dir}:\$PATH\"" +fi + +info "" +info "Run 'hunk --help' to get started, and 'hunk update' to move to a newer release." diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 9de73014c..cf7f6cd0c 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -240,14 +240,14 @@ update Hunk with the package manager that installed it ```bash hunk update [version] hunk update --check -hunk update --method +hunk update --method ``` ### Command-specific options | Option | Description | | ------------------- | -------------------------------------------------------------- | -| `--method ` | install method instead of the detected one: npm, brew | +| `--method ` | install method instead of the detected one: npm, brew, curl | | `--check` | report the installed and available versions without installing | ## `hunk daemon serve` diff --git a/website/src/content/docs/docs/start/install.md b/website/src/content/docs/docs/start/install.md index df4d395d4..852d4e435 100644 --- a/website/src/content/docs/docs/start/install.md +++ b/website/src/content/docs/docs/start/install.md @@ -1,9 +1,38 @@ --- title: Install -description: Install Hunk with npm, Homebrew, mise, or Nix and verify the CLI. +description: Install Hunk with the install script, npm, Homebrew, mise, or Nix and verify the CLI. --- -Hunk runs on macOS, Linux, and Windows. npm installs require Node.js 18 or newer; Homebrew, mise, and Nix installs are self-contained binaries. Git is recommended for the most common review workflows. +Hunk runs on macOS, Linux, and Windows. npm installs require Node.js 18 or newer; the install script, Homebrew, mise, and Nix installs are self-contained binaries. Git is recommended for the most common review workflows. + +## Install script + +On macOS and Linux, the install script downloads the prebuilt binary for your machine: + +```bash +curl -fsSL https://hunk.dev/install.sh | sh +hunk --version +``` + +It verifies the downloaded archive against the release's published `SHA256SUMS`, installs into `~/.hunk` (binary at `~/.hunk/bin/hunk`, bundled agent skills beside it), and adds `~/.hunk/bin` to `PATH` in your shell's startup file. Restart your shell afterwards. + +The script reads three settings: + +| Setting | Effect | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `HUNK_VERSION` | Install an exact release instead of the newest one. Also accepted as a positional argument. | +| `HUNK_INSTALL_DIR` | Install the binary into this directory instead of `~/.hunk/bin`. | +| `--no-modify-path` (or `HUNK_NO_MODIFY_PATH=1`) | Leave shell startup files alone. | + +```bash +curl -fsSL https://hunk.dev/install.sh | sh -s -- 0.19.0 +curl -fsSL https://hunk.dev/install.sh | sh -s -- --no-modify-path +curl -fsSL https://hunk.dev/install.sh | HUNK_VERSION=0.19.0 sh +``` + +`hunk update` refreshes a default install in place. An install redirected with `HUNK_INSTALL_DIR` is treated as a local build instead, because that is also where `bun run install:bin` writes; rerun the script to update one of those. + +Windows is not covered by the script; use npm there. ## npm @@ -60,7 +89,7 @@ See the repository's `nix/README.md` for Home Manager and development-shell deta hunk --help ``` -You should see `Usage: hunk [options]`. If the shell cannot find Hunk, ensure your global npm, Homebrew, or mise binary directory is on `PATH`, then open a new shell. +You should see `Usage: hunk [options]`. If the shell cannot find Hunk, ensure your global npm, Homebrew, mise, or `~/.hunk/bin` directory is on `PATH`, then open a new shell. ## Update Hunk @@ -72,6 +101,6 @@ hunk update --check # report the installed and available versions hunk update 0.19.0 # install a specific npm release ``` -npm installs (including `bun` and `pnpm` global installs) and Homebrew installs update in place. mise, Nix, and local source builds are owned by their own tooling, so Hunk prints the command that updates them — `mise up hunk`, your Nix configuration, or `bun run install:bin` — instead of updating itself. Pass `--method npm` or `--method brew` if Hunk detects the wrong one. +npm installs (including `bun` and `pnpm` global installs), Homebrew installs, and install-script installs update in place; a curl install re-runs the install script with the target version. mise, Nix, and local source builds are owned by their own tooling, so Hunk prints the command that updates them — `mise up hunk`, your Nix configuration, or `bun run install:bin` — instead of updating itself. Pass `--method npm`, `--method brew`, or `--method curl` if Hunk detects the wrong one. Next, [review your first working tree](/docs/start/quick-start/). From 0f0a45fa5b6e34e06469704024632b091692044d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 01:28:03 +0000 Subject: [PATCH 2/7] fix(cli): harden curl installer against truncation and partial swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the install script in a main function invoked on its last line so a truncated curl-to-sh stream dies on a syntax error instead of executing a prefix of the install, swap the bundled skills through renames so an existing install never has a window with no skills, exit explicitly on INT/TERM so an interrupted run cannot resume past its own cleanup, fall back to wget when hunk update re-runs the installer on a curl-less machine, and correct the docs and detection comment for custom-directory installs, which are not auto-detectable once the installing shell exits — the installer now says so and prints the re-run command instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- scripts/install-sh.test.ts | 26 +- src/core/process/installSource.ts | 10 +- src/core/process/selfUpdate.test.ts | 8 +- src/core/process/selfUpdate.ts | 8 +- website/public/install.sh | 364 +++++++++--------- .../src/content/docs/docs/start/install.md | 2 +- 6 files changed, 225 insertions(+), 193 deletions(-) diff --git a/scripts/install-sh.test.ts b/scripts/install-sh.test.ts index 68ad27ce1..d1d66daf9 100644 --- a/scripts/install-sh.test.ts +++ b/scripts/install-sh.test.ts @@ -24,14 +24,15 @@ const CURL_INSTALLABLE_SPECS = PLATFORM_PACKAGE_MATRIX.filter((spec) => spec.os /** * Run the installer's platform detection with a stubbed `uname` and print ` `. * - * The script is sourced with its own body truncated at the detection call, so nothing downloads: - * the stub shadows `uname` (and `sysctl`) as shell functions, which take precedence over the real - * executables. + * The script keeps every statement inside `main`, called on its last line, so everything before + * `main() {` is pure function definitions: sourcing that prefix runs nothing, and the harness can + * call the detection functions directly. The stubs shadow `uname` (and `sysctl`) as shell + * functions, which take precedence over the real executables. */ function detectPlatform(unameSystem: string, unameMachine: string, translated = "0") { const scriptDir = mkdtempSync(join(tmpdir(), "hunk-install-sh-")); const harnessPath = join(scriptDir, "detect.sh"); - const [detectionBody] = INSTALL_SCRIPT.split('package_name="hunkdiff-'); + const [definitionsBody] = INSTALL_SCRIPT.split("main() {"); try { writeFileSync( @@ -41,8 +42,10 @@ function detectPlatform(unameSystem: string, unameMachine: string, translated = "set -eu", `uname() { if [ "\${1:-}" = "-m" ]; then printf '%s\\n' '${unameMachine}'; else printf '%s\\n' '${unameSystem}'; fi; }`, `sysctl() { printf '%s\\n' '${translated}'; }`, - // The installer's own text, stopping just before it starts naming release archives. - detectionBody ?? "", + // The installer's function definitions, without the main invocation. + definitionsBody ?? "", + 'os="$(detect_os)"', + 'arch="$(detect_arch)"', 'printf "%s %s\\n" "$os" "$arch"', "", ].join("\n"), @@ -87,10 +90,19 @@ describe("hunk.dev install script", () => { // `resolveBundledSkillPath` walks up from the binary looking for `skills//SKILL.md`, // so the payload directory must be the binary's directory or one of its ancestors. expect(INSTALL_SCRIPT).toContain('bin_dir="${payload_dir}/bin"'); - expect(INSTALL_SCRIPT).toContain('mv "${temp_dir}/extract/skills" "${payload_dir}/skills"'); + expect(INSTALL_SCRIPT).toContain('mv "${temp_dir}/extract/skills" "${payload_dir}/skills.new"'); + expect(INSTALL_SCRIPT).toContain('mv "${payload_dir}/skills.new" "${payload_dir}/skills"'); expect(INSTALL_SCRIPT).toContain("--strip-components=1"); }); + test("defers every statement to a main call on the last line", () => { + // A `curl | sh` pipe executes statements as they stream in, so a truncated download must die + // on an unclosed function body instead of running a prefix of the install. + const lines = INSTALL_SCRIPT.trimEnd().split("\n"); + expect(lines.at(-1)).toBe('main "$@"'); + expect(INSTALL_SCRIPT).toContain("main() {"); + }); + test("points unsupported platforms at the npm package", () => { expect(INSTALL_SCRIPT).toContain("npm install -g hunkdiff"); }); diff --git a/src/core/process/installSource.ts b/src/core/process/installSource.ts index 8c30630f9..2ed3237fb 100644 --- a/src/core/process/installSource.ts +++ b/src/core/process/installSource.ts @@ -195,10 +195,12 @@ export function detectInstallSource(facts: InstallSourceFacts = {}): InstallSour return "curl"; } - // Boundary: a curl install redirected elsewhere with `HUNK_INSTALL_DIR` classifies as `dev`, - // because that variable already names the directory `bun run install:bin` writes to. The two - // channels are indistinguishable once they share a directory the user chose, and `dev` is the - // safe answer — it prints the command to rerun instead of replacing a binary Hunk does not own. + // Boundary: a curl install redirected elsewhere with `HUNK_INSTALL_DIR` is only recognizable + // while that variable is still exported — it names the directory `bun run install:bin` also + // writes to, so the match below classifies it as `dev`, which safely prints a rerun command. + // Once the installing shell exits the variable is gone, the custom directory matches nothing, + // and detection falls through to `npm`; the installer therefore ends a custom-directory run by + // telling the user to update by re-running it with the same `HUNK_INSTALL_DIR`. const homeDir = facts.homeDir ?? env.HOME ?? env.USERPROFILE; if (isInsideDirectory(executablePath, resolveDevInstallDir(env, homeDir))) { return "dev"; diff --git a/src/core/process/selfUpdate.test.ts b/src/core/process/selfUpdate.test.ts index 0424647a7..27cc64937 100644 --- a/src/core/process/selfUpdate.test.ts +++ b/src/core/process/selfUpdate.test.ts @@ -71,6 +71,10 @@ async function runUpdate(options: UpdateRunOptions) { }; } +/** The exact pipeline `hunk update` spawns for curl installs, wget standing in for curl. */ +const CURL_UPDATE_PIPELINE = + "{ if command -v curl >/dev/null 2>&1; then curl -fsSL https://hunk.dev/install.sh; else wget -qO- https://hunk.dev/install.sh; fi; } | sh"; + describe("update method parsing", () => { test("normalizes brew to the Homebrew install source", () => { expect(parseUpdateMethod("brew")).toBe("homebrew"); @@ -191,7 +195,7 @@ describe("hunk update", () => { }); expect(result.exitCode).toBe(0); - expect(result.commands).toEqual([["sh", "-c", "curl -fsSL https://hunk.dev/install.sh | sh"]]); + expect(result.commands).toEqual([["sh", "-c", CURL_UPDATE_PIPELINE]]); // The installer resolves the version from its environment, so the child carries the target // alongside the rest of this process's environment. expect(result.commandEnvs).toEqual([ @@ -208,7 +212,7 @@ describe("hunk update", () => { }); expect(result.exitCode).toBe(0); - expect(result.commands).toEqual([["sh", "-c", "curl -fsSL https://hunk.dev/install.sh | sh"]]); + expect(result.commands).toEqual([["sh", "-c", CURL_UPDATE_PIPELINE]]); expect(result.commandEnvs[0]?.HUNK_VERSION).toBe("0.9.0"); }); diff --git a/src/core/process/selfUpdate.ts b/src/core/process/selfUpdate.ts index 22fc8fc5e..5fa5b53e3 100644 --- a/src/core/process/selfUpdate.ts +++ b/src/core/process/selfUpdate.ts @@ -154,11 +154,13 @@ function npmUpdateCommand( * The installer is the updater: it already resolves the platform archive, verifies its checksum, * and swaps the tree in place, so duplicating any of that here would give curl installs a second * download path that can drift from the one users pipe into `sh`. The target version travels in - * the child environment rather than the command line so the pipeline stays byte-identical to the - * documented one-liner. + * the child environment rather than the command line so the pipeline stays equivalent to the + * documented one-liner, and wget stands in when curl is absent — the installer itself supports + * wget-only machines, so its updater must too. */ function curlUpdateCommand() { - return ["sh", "-c", `curl -fsSL ${CURL_INSTALL_SCRIPT_URL} | sh`]; + const fetchScript = `if command -v curl >/dev/null 2>&1; then curl -fsSL ${CURL_INSTALL_SCRIPT_URL}; else wget -qO- ${CURL_INSTALL_SCRIPT_URL}; fi`; + return ["sh", "-c", `{ ${fetchScript}; } | sh`]; } /** Choose the command that installs one target version for the channel that owns this binary. */ diff --git a/website/public/install.sh b/website/public/install.sh index 31e01db62..858749f99 100755 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -17,6 +17,9 @@ # HUNK_NO_MODIFY_PATH set to 1 to leave shell startup files alone # # macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. +# +# Everything below only defines functions; the last line runs main. A partially delivered +# script therefore dies on a syntax error instead of executing a truncated prefix. set -eu @@ -64,35 +67,6 @@ macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. EOF } -# -------------------------------------------------------------------------------------- -# Arguments -# -------------------------------------------------------------------------------------- - -version="${HUNK_VERSION:-}" -no_modify_path="${HUNK_NO_MODIFY_PATH:-0}" - -while [ "$#" -gt 0 ]; do - case "$1" in - -h | --help) - usage - exit 0 - ;; - --no-modify-path) - no_modify_path=1 - ;; - -*) - fail "Unknown option: $1 (run with --help to see the supported options)" - ;; - *) - version="$1" - ;; - esac - shift -done - -# Release tags are spelled `v1.2.3`; asset names and `--version` output are not. -version="${version#v}" - # -------------------------------------------------------------------------------------- # Platform detection # -------------------------------------------------------------------------------------- @@ -132,23 +106,10 @@ detect_arch() { esac } -os="$(detect_os)" -arch="$(detect_arch)" -package_name="hunkdiff-${os}-${arch}" -archive_name="${package_name}.tar.gz" - # -------------------------------------------------------------------------------------- # Downloading # -------------------------------------------------------------------------------------- -if command -v curl >/dev/null 2>&1; then - downloader="curl" -elif command -v wget >/dev/null 2>&1; then - downloader="wget" -else - fail "Neither curl nor wget is available. Install one of them and try again." -fi - # Download one URL to one path, returning non-zero when the server refuses it. download() { if [ "$downloader" = "curl" ]; then @@ -167,37 +128,6 @@ fetch() { fi } -# -------------------------------------------------------------------------------------- -# Version resolution -# -------------------------------------------------------------------------------------- - -if [ -z "$version" ]; then - info "Resolving the newest Hunk release..." - # Parsed with sed rather than jq so the installer needs nothing but a shell and a downloader. - version="$(fetch "$RELEASES_API" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)" - [ -n "$version" ] || fail "Could not resolve the newest Hunk release from ${RELEASES_API}." -fi - -# -------------------------------------------------------------------------------------- -# Install layout -# -------------------------------------------------------------------------------------- - -home_dir="${HOME:-}" -[ -n "$home_dir" ] || fail "HOME is not set, so there is nowhere to install Hunk." - -if [ -n "${HUNK_INSTALL_DIR:-}" ]; then - bin_dir="$HUNK_INSTALL_DIR" - # The bundled skills are found by walking up from the binary, so a chosen directory holds - # both. `hunk update` reports this install as a local build rather than a curl install, - # because HUNK_INSTALL_DIR is also where `bun run install:bin` writes. - payload_dir="$HUNK_INSTALL_DIR" -else - payload_dir="${home_dir}/.hunk" - bin_dir="${payload_dir}/bin" -fi - -target_binary="${bin_dir}/hunk" - # -------------------------------------------------------------------------------------- # Already-current check # -------------------------------------------------------------------------------------- @@ -208,83 +138,10 @@ installed_version() { "$1" --version 2>/dev/null | tr -d 'v \t\r' | head -n 1 } -current="$(installed_version "$target_binary")" -if [ -z "$current" ] && command -v hunk >/dev/null 2>&1; then - current="$(installed_version "$(command -v hunk)")" -fi - -if [ "$current" = "$version" ]; then - info "hunk ${version} is already installed." - exit 0 -fi - # -------------------------------------------------------------------------------------- -# Download, verify, install +# PATH helpers # -------------------------------------------------------------------------------------- -temp_dir="$(mktemp -d)" -cleanup() { - rm -rf "$temp_dir" -} -trap cleanup EXIT INT TERM - -archive_url="${DOWNLOAD_BASE}/v${version}/${archive_name}" -info "Downloading ${archive_name} (v${version})..." -download "$archive_url" "${temp_dir}/${archive_name}" || - fail "Could not download ${archive_url}. Check that the version exists and that this platform is published." - -# Checksums ship as one SHA256SUMS asset covering every archive in the release. Releases made -# before that asset existed still install, with a warning rather than a silent skip. -if download "${DOWNLOAD_BASE}/v${version}/SHA256SUMS" "${temp_dir}/SHA256SUMS" 2>/dev/null; then - if command -v sha256sum >/dev/null 2>&1; then - checksum_tool="sha256sum" - elif command -v shasum >/dev/null 2>&1; then - checksum_tool="shasum -a 256" - else - checksum_tool="" - fi - - if [ -n "$checksum_tool" ]; then - grep " \{1,2\}${archive_name}\$" "${temp_dir}/SHA256SUMS" >"${temp_dir}/SHA256SUMS.one" || - fail "SHA256SUMS has no entry for ${archive_name}. Refusing to install an unverified archive." - info "Verifying checksum..." - (cd "$temp_dir" && $checksum_tool -c SHA256SUMS.one >/dev/null) || - fail "Checksum verification failed for ${archive_name}. Refusing to install a corrupted or tampered archive." - else - warn "Neither sha256sum nor shasum is available, so the archive checksum was not verified." - fi -else - warn "This release publishes no SHA256SUMS asset, so the archive checksum was not verified." -fi - -info "Installing to ${bin_dir}..." -mkdir -p "${temp_dir}/extract" -# The archive holds one top-level `hunkdiff--/` directory with the binary, the bundled -# skills, and metadata.json inside it; stripping that wrapper puts the payload at the root. -tar -xzf "${temp_dir}/${archive_name}" -C "${temp_dir}/extract" --strip-components=1 -[ -f "${temp_dir}/extract/hunk" ] || fail "The downloaded archive contains no hunk binary." -chmod 0755 "${temp_dir}/extract/hunk" - -mkdir -p "$payload_dir" "$bin_dir" -# Replace the skills and metadata first, then move the binary last and through a same-directory -# rename, so a Hunk that is running right now is never left pointing at a half-written tree. -rm -rf "${payload_dir}/skills" -mv "${temp_dir}/extract/skills" "${payload_dir}/skills" -if [ -f "${temp_dir}/extract/metadata.json" ]; then - mv -f "${temp_dir}/extract/metadata.json" "${payload_dir}/metadata.json" -fi -mv -f "${temp_dir}/extract/hunk" "${bin_dir}/hunk.new" -mv -f "${bin_dir}/hunk.new" "$target_binary" -chmod 0755 "$target_binary" - -info "Installed hunk ${version} to ${target_binary}" - -# -------------------------------------------------------------------------------------- -# PATH -# -------------------------------------------------------------------------------------- - -path_line="export PATH=\"${bin_dir}:\$PATH\"" - # Append one line to one file unless an equivalent line is already there. Prints what it did. add_path_line() { rc_file="$1" @@ -311,33 +168,188 @@ first_existing() { printf '%s\n' "$fallback" } -if [ "$no_modify_path" = "1" ]; then - info "Left shell startup files untouched (--no-modify-path)." - info "Add ${bin_dir} to your PATH to run hunk from anywhere." -elif [ -n "${GITHUB_PATH:-}" ]; then - # GitHub Actions reads this file between steps, so no shell startup file is involved. - printf '%s\n' "$bin_dir" >>"$GITHUB_PATH" - info "Added ${bin_dir} to \$GITHUB_PATH for later workflow steps." -else - shell_name="$(basename "${SHELL:-sh}")" - case "$shell_name" in - zsh) - add_path_line "${ZDOTDIR:-$home_dir}/.zshrc" "$path_line" - ;; - bash) - add_path_line \ - "$(first_existing "${home_dir}/.bashrc" "${home_dir}/.bash_profile" "${home_dir}/.profile")" \ - "$path_line" - ;; - fish) - add_path_line "${home_dir}/.config/fish/config.fish" "fish_add_path \"${bin_dir}\"" - ;; - *) - add_path_line "${home_dir}/.profile" "$path_line" - ;; - esac - info "Restart your shell, or run: export PATH=\"${bin_dir}:\$PATH\"" -fi +# -------------------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------------------- + +main() { + version="${HUNK_VERSION:-}" + no_modify_path="${HUNK_NO_MODIFY_PATH:-0}" + + while [ "$#" -gt 0 ]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --no-modify-path) + no_modify_path=1 + ;; + -*) + fail "Unknown option: $1 (run with --help to see the supported options)" + ;; + *) + version="$1" + ;; + esac + shift + done + + # Release tags are spelled `v1.2.3`; asset names and `--version` output are not. + version="${version#v}" + + os="$(detect_os)" + arch="$(detect_arch)" + package_name="hunkdiff-${os}-${arch}" + archive_name="${package_name}.tar.gz" + + if command -v curl >/dev/null 2>&1; then + downloader="curl" + elif command -v wget >/dev/null 2>&1; then + downloader="wget" + else + fail "Neither curl nor wget is available. Install one of them and try again." + fi + + if [ -z "$version" ]; then + info "Resolving the newest Hunk release..." + # Parsed with sed rather than jq so the installer needs nothing but a shell and a downloader. + version="$(fetch "$RELEASES_API" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1)" + [ -n "$version" ] || fail "Could not resolve the newest Hunk release from ${RELEASES_API}." + fi + + home_dir="${HOME:-}" + [ -n "$home_dir" ] || fail "HOME is not set, so there is nowhere to install Hunk." + + custom_dir="" + if [ -n "${HUNK_INSTALL_DIR:-}" ]; then + bin_dir="$HUNK_INSTALL_DIR" + # The bundled skills are found by walking up from the binary, so a chosen directory holds + # both. `hunk update` cannot recognize a custom directory once this shell exits, so the + # install finishes with re-run guidance instead (see the note printed at the end). + payload_dir="$HUNK_INSTALL_DIR" + custom_dir="$HUNK_INSTALL_DIR" + else + payload_dir="${home_dir}/.hunk" + bin_dir="${payload_dir}/bin" + fi + + target_binary="${bin_dir}/hunk" + + current="$(installed_version "$target_binary")" + if [ -z "$current" ] && command -v hunk >/dev/null 2>&1; then + current="$(installed_version "$(command -v hunk)")" + fi + + if [ "$current" = "$version" ]; then + info "hunk ${version} is already installed." + exit 0 + fi + + temp_dir="$(mktemp -d)" + cleanup() { + rm -rf "$temp_dir" + } + # INT/TERM exit explicitly so the shell cannot resume mid-install; EXIT then runs cleanup. + trap cleanup EXIT + trap 'exit 1' INT TERM + + archive_url="${DOWNLOAD_BASE}/v${version}/${archive_name}" + info "Downloading ${archive_name} (v${version})..." + download "$archive_url" "${temp_dir}/${archive_name}" || + fail "Could not download ${archive_url}. Check that the version exists and that this platform is published." + + # Checksums ship as one SHA256SUMS asset covering every archive in the release. Releases made + # before that asset existed still install, with a warning rather than a silent skip. + if download "${DOWNLOAD_BASE}/v${version}/SHA256SUMS" "${temp_dir}/SHA256SUMS" 2>/dev/null; then + if command -v sha256sum >/dev/null 2>&1; then + checksum_tool="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + checksum_tool="shasum -a 256" + else + checksum_tool="" + fi + + if [ -n "$checksum_tool" ]; then + grep " \{1,2\}${archive_name}\$" "${temp_dir}/SHA256SUMS" >"${temp_dir}/SHA256SUMS.one" || + fail "SHA256SUMS has no entry for ${archive_name}. Refusing to install an unverified archive." + info "Verifying checksum..." + (cd "$temp_dir" && $checksum_tool -c SHA256SUMS.one >/dev/null) || + fail "Checksum verification failed for ${archive_name}. Refusing to install a corrupted or tampered archive." + else + warn "Neither sha256sum nor shasum is available, so the archive checksum was not verified." + fi + else + warn "This release publishes no SHA256SUMS asset, so the archive checksum was not verified." + fi + + info "Installing to ${bin_dir}..." + mkdir -p "${temp_dir}/extract" + # The archive holds one top-level `hunkdiff--/` directory with the binary, the bundled + # skills, and metadata.json inside it; stripping that wrapper puts the payload at the root. + tar -xzf "${temp_dir}/${archive_name}" -C "${temp_dir}/extract" --strip-components=1 + [ -f "${temp_dir}/extract/hunk" ] || fail "The downloaded archive contains no hunk binary." + chmod 0755 "${temp_dir}/extract/hunk" + + mkdir -p "$payload_dir" "$bin_dir" + # Swap the skills through renames — new tree in beside the old, old tree moved aside, then + # removed — so no window exists where an existing install has no skills at all. The binary + # moves last, through a same-directory rename, so a Hunk that is running right now is never + # left pointing at a half-written tree. + rm -rf "${payload_dir}/skills.new" "${payload_dir}/skills.old" + mv "${temp_dir}/extract/skills" "${payload_dir}/skills.new" + if [ -e "${payload_dir}/skills" ]; then + mv "${payload_dir}/skills" "${payload_dir}/skills.old" + fi + mv "${payload_dir}/skills.new" "${payload_dir}/skills" + rm -rf "${payload_dir}/skills.old" + if [ -f "${temp_dir}/extract/metadata.json" ]; then + mv -f "${temp_dir}/extract/metadata.json" "${payload_dir}/metadata.json" + fi + mv -f "${temp_dir}/extract/hunk" "${bin_dir}/hunk.new" + mv -f "${bin_dir}/hunk.new" "$target_binary" + chmod 0755 "$target_binary" + + info "Installed hunk ${version} to ${target_binary}" + + path_line="export PATH=\"${bin_dir}:\$PATH\"" + + if [ "$no_modify_path" = "1" ]; then + info "Left shell startup files untouched (--no-modify-path)." + info "Add ${bin_dir} to your PATH to run hunk from anywhere." + elif [ -n "${GITHUB_PATH:-}" ]; then + # GitHub Actions reads this file between steps, so no shell startup file is involved. + printf '%s\n' "$bin_dir" >>"$GITHUB_PATH" + info "Added ${bin_dir} to \$GITHUB_PATH for later workflow steps." + else + shell_name="$(basename "${SHELL:-sh}")" + case "$shell_name" in + zsh) + add_path_line "${ZDOTDIR:-$home_dir}/.zshrc" "$path_line" + ;; + bash) + add_path_line \ + "$(first_existing "${home_dir}/.bashrc" "${home_dir}/.bash_profile" "${home_dir}/.profile")" \ + "$path_line" + ;; + fish) + add_path_line "${home_dir}/.config/fish/config.fish" "fish_add_path \"${bin_dir}\"" + ;; + *) + add_path_line "${home_dir}/.profile" "$path_line" + ;; + esac + info "Restart your shell, or run: export PATH=\"${bin_dir}:\$PATH\"" + fi + + info "" + if [ -n "$custom_dir" ]; then + info "Note: hunk update cannot auto-detect this custom install directory. To update later," + info "re-run this installer with HUNK_INSTALL_DIR=${custom_dir}." + info "Run 'hunk --help' to get started." + else + info "Run 'hunk --help' to get started, and 'hunk update' to move to a newer release." + fi +} -info "" -info "Run 'hunk --help' to get started, and 'hunk update' to move to a newer release." +main "$@" diff --git a/website/src/content/docs/docs/start/install.md b/website/src/content/docs/docs/start/install.md index 852d4e435..a7b19c0da 100644 --- a/website/src/content/docs/docs/start/install.md +++ b/website/src/content/docs/docs/start/install.md @@ -30,7 +30,7 @@ curl -fsSL https://hunk.dev/install.sh | sh -s -- --no-modify-path curl -fsSL https://hunk.dev/install.sh | HUNK_VERSION=0.19.0 sh ``` -`hunk update` refreshes a default install in place. An install redirected with `HUNK_INSTALL_DIR` is treated as a local build instead, because that is also where `bun run install:bin` writes; rerun the script to update one of those. +`hunk update` refreshes a default install in place. An install redirected with `HUNK_INSTALL_DIR` cannot be auto-detected later (the variable is gone once your shell exits), so update one of those by re-running the script with the same `HUNK_INSTALL_DIR`; the installer prints a reminder at the end of a custom-directory install. Windows is not covered by the script; use npm there. From b72878fcbce3d9cf7df419a94579273b90b6316a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 01:50:37 +0000 Subject: [PATCH 3/7] fix(cli): fail curl updates loudly and keep swaps recoverable Download the installer to a file before executing it so a failed fetch surfaces its own error instead of feeding sh empty input and reporting a successful update, restore the parked skills tree from cleanup when an interrupted swap left it aside, and write the PATH line with a single-quoted directory so a custom install path containing shell-significant characters stays a literal path in shell startup files instead of becoming code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- src/core/process/selfUpdate.test.ts | 14 +++++++++++--- src/core/process/selfUpdate.ts | 19 +++++++++++++------ website/public/install.sh | 18 +++++++++++++++--- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/core/process/selfUpdate.test.ts b/src/core/process/selfUpdate.test.ts index 27cc64937..a2dfc9ee5 100644 --- a/src/core/process/selfUpdate.test.ts +++ b/src/core/process/selfUpdate.test.ts @@ -71,9 +71,17 @@ async function runUpdate(options: UpdateRunOptions) { }; } -/** The exact pipeline `hunk update` spawns for curl installs, wget standing in for curl. */ -const CURL_UPDATE_PIPELINE = - "{ if command -v curl >/dev/null 2>&1; then curl -fsSL https://hunk.dev/install.sh; else wget -qO- https://hunk.dev/install.sh; fi; } | sh"; +/** + * The exact script `hunk update` spawns for curl installs: download to a file, then execute, so + * a failed fetch aborts instead of feeding `sh` empty input; wget stands in for curl. + */ +const CURL_UPDATE_PIPELINE = [ + "set -e", + 'tmp="$(mktemp)"', + "trap 'rm -f \"$tmp\"' EXIT", + 'if command -v curl >/dev/null 2>&1; then curl -fsSL https://hunk.dev/install.sh -o "$tmp"; else wget -qO "$tmp" https://hunk.dev/install.sh; fi', + 'sh "$tmp"', +].join("; "); describe("update method parsing", () => { test("normalizes brew to the Homebrew install source", () => { diff --git a/src/core/process/selfUpdate.ts b/src/core/process/selfUpdate.ts index 5fa5b53e3..79ea4ef7c 100644 --- a/src/core/process/selfUpdate.ts +++ b/src/core/process/selfUpdate.ts @@ -153,14 +153,21 @@ function npmUpdateCommand( * * The installer is the updater: it already resolves the platform archive, verifies its checksum, * and swaps the tree in place, so duplicating any of that here would give curl installs a second - * download path that can drift from the one users pipe into `sh`. The target version travels in - * the child environment rather than the command line so the pipeline stays equivalent to the - * documented one-liner, and wget stands in when curl is absent — the installer itself supports - * wget-only machines, so its updater must too. + * download path that can drift from the one users pipe into `sh`. The script downloads to a file + * before executing — piping it straight into `sh` would report the pipeline's last status, so a + * failed fetch would feed `sh` empty input and read as a successful update. wget stands in when + * curl is absent, since the installer itself supports wget-only machines. The target version + * travels in the child environment, where the installer reads `HUNK_VERSION`. */ function curlUpdateCommand() { - const fetchScript = `if command -v curl >/dev/null 2>&1; then curl -fsSL ${CURL_INSTALL_SCRIPT_URL}; else wget -qO- ${CURL_INSTALL_SCRIPT_URL}; fi`; - return ["sh", "-c", `{ ${fetchScript}; } | sh`]; + const script = [ + "set -e", + 'tmp="$(mktemp)"', + "trap 'rm -f \"$tmp\"' EXIT", + `if command -v curl >/dev/null 2>&1; then curl -fsSL ${CURL_INSTALL_SCRIPT_URL} -o "$tmp"; else wget -qO "$tmp" ${CURL_INSTALL_SCRIPT_URL}; fi`, + 'sh "$tmp"', + ].join("; "); + return ["sh", "-c", script]; } /** Choose the command that installs one target version for the channel that owns this binary. */ diff --git a/website/public/install.sh b/website/public/install.sh index 858749f99..67f8998d4 100755 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -156,6 +156,12 @@ add_path_line() { info "Added ${bin_dir} to PATH in ${rc_file}." } +# Escape one value for inclusion inside single quotes in shell startup syntax, so a directory +# containing shell-significant characters stays a literal path instead of becoming code. +squote() { + printf "%s" "$1" | sed "s/'/'\\\\''/g" +} + # Print the first of the given candidate startup files that exists, or the first candidate. first_existing() { fallback="$1" @@ -248,6 +254,11 @@ main() { temp_dir="$(mktemp -d)" cleanup() { + # A swap interrupted between renames leaves the previous skills tree parked at skills.old; + # put it back so an already-installed binary keeps resolving its bundled skills. + if [ ! -e "${payload_dir}/skills" ] && [ -e "${payload_dir}/skills.old" ]; then + mv "${payload_dir}/skills.old" "${payload_dir}/skills" + fi rm -rf "$temp_dir" } # INT/TERM exit explicitly so the shell cannot resume mid-install; EXIT then runs cleanup. @@ -312,7 +323,8 @@ main() { info "Installed hunk ${version} to ${target_binary}" - path_line="export PATH=\"${bin_dir}:\$PATH\"" + quoted_bin_dir="'$(squote "$bin_dir")'" + path_line="export PATH=${quoted_bin_dir}:\"\$PATH\"" if [ "$no_modify_path" = "1" ]; then info "Left shell startup files untouched (--no-modify-path)." @@ -333,13 +345,13 @@ main() { "$path_line" ;; fish) - add_path_line "${home_dir}/.config/fish/config.fish" "fish_add_path \"${bin_dir}\"" + add_path_line "${home_dir}/.config/fish/config.fish" "fish_add_path ${quoted_bin_dir}" ;; *) add_path_line "${home_dir}/.profile" "$path_line" ;; esac - info "Restart your shell, or run: export PATH=\"${bin_dir}:\$PATH\"" + info "Restart your shell, or run: export PATH=${quoted_bin_dir}:\"\$PATH\"" fi info "" From 4b484bf0ce785c32cca383aa8c30699c72ac14a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:12:35 +0000 Subject: [PATCH 4/7] fix(cli): keep installer PATH append idempotent for quoted paths Match the exact line the installer writes instead of the raw directory, which no longer appears verbatim once quoting escapes it, so re-running the installer against a path containing shell-special characters cannot append duplicate PATH blocks. Also escape the dot in the vercel header route so it matches only the literal install.sh path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- vercel.json | 2 +- website/public/install.sh | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/vercel.json b/vercel.json index ac1dbb069..30ece04c9 100644 --- a/vercel.json +++ b/vercel.json @@ -8,7 +8,7 @@ "rewrites": [{ "source": "/install", "destination": "/install.sh" }], "headers": [ { - "source": "/install(.sh)?", + "source": "/install(\\.sh)?", "headers": [{ "key": "Content-Type", "value": "text/plain; charset=utf-8" }] } ] diff --git a/website/public/install.sh b/website/public/install.sh index 67f8998d4..086f4067f 100755 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -146,7 +146,9 @@ installed_version() { add_path_line() { rc_file="$1" line="$2" - if [ -f "$rc_file" ] && grep -Fq "$bin_dir" "$rc_file"; then + # Match the exact line this installer writes — the raw directory does not appear verbatim once + # quoting escapes it, so grepping for it would re-append on every run for such paths. + if [ -f "$rc_file" ] && grep -Fq "$line" "$rc_file"; then info "${rc_file} already puts ${bin_dir} on PATH." return 0 fi From 83829833451f087dd1b4691a55c8017e955a402e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:26:03 +0000 Subject: [PATCH 5/7] ci: run the install script end to end against published releases Exercise the real download, checksum, extract, skill-resolution, and PATH flow on ubuntu and macos whenever the script changes, weekly to catch release-asset drift, and on demand. The release version is resolved with an authenticated gh call so shared-runner API rate limits cannot fail runs the script did not cause. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- .github/workflows/install-sh-e2e.yml | 99 ++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/install-sh-e2e.yml diff --git a/.github/workflows/install-sh-e2e.yml b/.github/workflows/install-sh-e2e.yml new file mode 100644 index 000000000..bcdcda09d --- /dev/null +++ b/.github/workflows/install-sh-e2e.yml @@ -0,0 +1,99 @@ +# Runs the hunk.dev install script end to end against the newest published release. +# +# The unit suite (scripts/install-sh.test.ts) checks the script as text and exercises its +# functions in isolation; this workflow is the only place the real download, checksum, extract, +# and PATH flow runs against real release assets. It triggers on changes to the script itself, +# on a weekly schedule to catch release-asset drift the script did not cause, and on demand. +# +# The release version is resolved with an authenticated `gh` call and pinned via HUNK_VERSION +# instead of exercising the script's own anonymous latest-release lookup: shared runner IPs hit +# GitHub's unauthenticated API rate limits, and a rate-limited lookup would fail runs the script +# did nothing to deserve. The lookup's parsing is covered by the unit suite. +name: Install script E2E + +on: + pull_request: + paths: + - website/public/install.sh + - .github/workflows/install-sh-e2e.yml + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + install-e2e: + name: Install script E2E (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + # dash serves /bin/sh on ubuntu and bash-as-sh on macos, so both POSIX modes get exercised. + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Resolve the newest published release + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)" + echo "version=${tag#v}" >> "$GITHUB_OUTPUT" + + - name: Install into a sandbox home + env: + HUNK_VERSION: ${{ steps.release.outputs.version }} + run: | + set -eu + sandbox="$(mktemp -d)" + github_path="${sandbox}/github_path" + : >"$github_path" + + HOME="$sandbox" GITHUB_PATH="$github_path" sh website/public/install.sh + + binary="${sandbox}/.hunk/bin/hunk" + [ -x "$binary" ] || { echo "error: no executable at $binary" >&2; exit 1; } + "$binary" --version | grep -F "$HUNK_VERSION" + skill="$("$binary" skill path hunk-review)" + [ -f "$skill" ] || { echo "error: skill path $skill does not exist" >&2; exit 1; } + grep -F "${sandbox}/.hunk/bin" "$github_path" + + - name: Re-run is idempotent + env: + HUNK_VERSION: ${{ steps.release.outputs.version }} + run: | + set -eu + sandbox="$(mktemp -d)" + HOME="$sandbox" GITHUB_PATH="${sandbox}/github_path" sh website/public/install.sh + output="$(HOME="$sandbox" GITHUB_PATH="${sandbox}/github_path" sh website/public/install.sh)" + printf '%s\n' "$output" | grep -F "already installed" + + - name: Custom directory with spaces installs and resolves skills + env: + HUNK_VERSION: ${{ steps.release.outputs.version }} + run: | + set -eu + sandbox="$(mktemp -d)" + custom="${sandbox}/hunk tools" + + HOME="$sandbox" HUNK_INSTALL_DIR="$custom" sh website/public/install.sh --no-modify-path + + binary="${custom}/hunk" + [ -x "$binary" ] || { echo "error: no executable at $binary" >&2; exit 1; } + "$binary" --version | grep -F "$HUNK_VERSION" + skill="$("$binary" skill path hunk-review)" + [ -f "$skill" ] || { echo "error: skill path $skill does not exist" >&2; exit 1; } + + - name: Unknown version fails loudly + run: | + set -eu + sandbox="$(mktemp -d)" + if HOME="$sandbox" HUNK_VERSION="0.0.999" sh website/public/install.sh --no-modify-path 2>"${sandbox}/err"; then + echo "error: installing a nonexistent version should have failed" >&2 + exit 1 + fi + grep -F "Could not download" "${sandbox}/err" From ed46158006f205a5943b19e2864277f20a24dfe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 13:54:57 +0000 Subject: [PATCH 6/7] refactor: make repo-root install.sh the canonical installer source The install script is a product artifact with its own tests, CI, and release-pipeline contract, not site content, so it moves from website/public/ to the repository root where contributors and read-before-you-pipe users can find it. The website build stages it into the deploy output as its final step, keeping the served copy in lockstep with the same deploy, and the vercel ignore rule now includes it so script changes still trigger a site deploy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- .github/workflows/install-sh-e2e.yml | 12 +++++------ website/public/install.sh => install.sh | 3 +++ package.json | 2 +- scripts/install-sh.test.ts | 5 +++-- scripts/stage-install-script.ts | 28 +++++++++++++++++++++++++ vercel.json | 2 +- 6 files changed, 42 insertions(+), 10 deletions(-) rename website/public/install.sh => install.sh (98%) create mode 100644 scripts/stage-install-script.ts diff --git a/.github/workflows/install-sh-e2e.yml b/.github/workflows/install-sh-e2e.yml index bcdcda09d..52074ef5f 100644 --- a/.github/workflows/install-sh-e2e.yml +++ b/.github/workflows/install-sh-e2e.yml @@ -14,7 +14,7 @@ name: Install script E2E on: pull_request: paths: - - website/public/install.sh + - install.sh - .github/workflows/install-sh-e2e.yml schedule: - cron: "17 6 * * 1" @@ -53,7 +53,7 @@ jobs: github_path="${sandbox}/github_path" : >"$github_path" - HOME="$sandbox" GITHUB_PATH="$github_path" sh website/public/install.sh + HOME="$sandbox" GITHUB_PATH="$github_path" sh install.sh binary="${sandbox}/.hunk/bin/hunk" [ -x "$binary" ] || { echo "error: no executable at $binary" >&2; exit 1; } @@ -68,8 +68,8 @@ jobs: run: | set -eu sandbox="$(mktemp -d)" - HOME="$sandbox" GITHUB_PATH="${sandbox}/github_path" sh website/public/install.sh - output="$(HOME="$sandbox" GITHUB_PATH="${sandbox}/github_path" sh website/public/install.sh)" + HOME="$sandbox" GITHUB_PATH="${sandbox}/github_path" sh install.sh + output="$(HOME="$sandbox" GITHUB_PATH="${sandbox}/github_path" sh install.sh)" printf '%s\n' "$output" | grep -F "already installed" - name: Custom directory with spaces installs and resolves skills @@ -80,7 +80,7 @@ jobs: sandbox="$(mktemp -d)" custom="${sandbox}/hunk tools" - HOME="$sandbox" HUNK_INSTALL_DIR="$custom" sh website/public/install.sh --no-modify-path + HOME="$sandbox" HUNK_INSTALL_DIR="$custom" sh install.sh --no-modify-path binary="${custom}/hunk" [ -x "$binary" ] || { echo "error: no executable at $binary" >&2; exit 1; } @@ -92,7 +92,7 @@ jobs: run: | set -eu sandbox="$(mktemp -d)" - if HOME="$sandbox" HUNK_VERSION="0.0.999" sh website/public/install.sh --no-modify-path 2>"${sandbox}/err"; then + if HOME="$sandbox" HUNK_VERSION="0.0.999" sh install.sh --no-modify-path 2>"${sandbox}/err"; then echo "error: installing a nonexistent version should have failed" >&2 exit 1 fi diff --git a/website/public/install.sh b/install.sh similarity index 98% rename from website/public/install.sh rename to install.sh index 086f4067f..48ab99cb3 100755 --- a/website/public/install.sh +++ b/install.sh @@ -18,6 +18,9 @@ # # macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. # +# This file's canonical home is the repository root; the website build stages it into the +# deploy output so hunk.dev serves it (scripts/stage-install-script.ts). +# # Everything below only defines functions; the last line runs main. A partially delivered # script therefore dies on a syntax error instead of executing a truncated prefix. diff --git a/package.json b/package.json index 681eec357..5b729e50d 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "check:docs": "bun run ./scripts/generate-docs.ts --check", "check:changelog": "bun run ./scripts/generate-changelog.ts --check", "website:dev": "bun run --cwd website dev", - "website:build": "bun run check:docs && bun run check:changelog && bun run --cwd website build", + "website:build": "bun run check:docs && bun run check:changelog && bun run --cwd website build && bun run ./scripts/stage-install-script.ts", "website:check": "bun run check:docs && bun run check:changelog && bun run --cwd website check", "website:links": "bun run ./scripts/check-website-links.ts", "website:test:browser": "bun run --cwd website test:browser", diff --git a/scripts/install-sh.test.ts b/scripts/install-sh.test.ts index d1d66daf9..92bf58bc7 100644 --- a/scripts/install-sh.test.ts +++ b/scripts/install-sh.test.ts @@ -5,7 +5,8 @@ import { join, resolve } from "node:path"; import { PLATFORM_PACKAGE_MATRIX } from "./prebuilt-package-helpers"; /** - * Covers `website/public/install.sh`, the script published at https://hunk.dev/install.sh. + * Covers `install.sh` at the repository root, the script published at https://hunk.dev/install.sh + * (staged into the website build by `scripts/stage-install-script.ts`). * * The script is the one piece of Hunk that runs before Hunk exists, so it is checked as text and * as a shell program: it must parse under a POSIX shell, name the same release archives the @@ -15,7 +16,7 @@ import { PLATFORM_PACKAGE_MATRIX } from "./prebuilt-package-helpers"; */ const REPO_ROOT = resolve(import.meta.dir, ".."); -const INSTALL_SCRIPT_PATH = join(REPO_ROOT, "website", "public", "install.sh"); +const INSTALL_SCRIPT_PATH = join(REPO_ROOT, "install.sh"); const INSTALL_SCRIPT = readFileSync(INSTALL_SCRIPT_PATH, "utf8"); /** Platform pairs the installer serves: every published package except the Windows one. */ diff --git a/scripts/stage-install-script.ts b/scripts/stage-install-script.ts new file mode 100644 index 000000000..091b42351 --- /dev/null +++ b/scripts/stage-install-script.ts @@ -0,0 +1,28 @@ +import { copyFileSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/** + * Stages the repo-root install script into the built website so hunk.dev/install.sh serves it. + * + * The script's canonical home is `install.sh` at the repository root — it is a product artifact + * with its own tests and release-pipeline contract, not site content — and the website build + * copies it into the deploy output as its final step. Runs after `astro build`, so a missing + * dist directory means the build order is wrong and the copy must fail rather than invent one. + */ + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const SOURCE = join(REPO_ROOT, "install.sh"); +const DIST_DIR = join(REPO_ROOT, "website", "dist"); + +if (!existsSync(SOURCE)) { + console.error(`stage-install-script: missing ${SOURCE}`); + process.exit(1); +} + +if (!existsSync(DIST_DIR)) { + console.error(`stage-install-script: ${DIST_DIR} does not exist; run the website build first.`); + process.exit(1); +} + +copyFileSync(SOURCE, join(DIST_DIR, "install.sh")); +console.log(`Staged install.sh into ${DIST_DIR}`); diff --git a/vercel.json b/vercel.json index 30ece04c9..b1a47230c 100644 --- a/vercel.json +++ b/vercel.json @@ -1,7 +1,7 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "astro", - "ignoreCommand": "git diff --quiet HEAD^ HEAD -- ./website ./vercel.json ./scripts/generate-docs.ts ./scripts/generate-changelog.ts ./CHANGELOG.md", + "ignoreCommand": "git diff --quiet HEAD^ HEAD -- ./website ./vercel.json ./install.sh ./scripts/stage-install-script.ts ./scripts/generate-docs.ts ./scripts/generate-changelog.ts ./CHANGELOG.md", "installCommand": "SKIP_INSTALL_SIMPLE_GIT_HOOKS=1 bun install --frozen-lockfile && bun install --cwd website --frozen-lockfile", "buildCommand": "bun run website:build", "outputDirectory": "website/dist", From 1e555402ebcfc19b529f0523cbdb87bb0ab6035f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 13:59:16 +0000 Subject: [PATCH 7/7] refactor(core): group the install lifecycle into core/install Move install-source detection, per-channel release lookup, and the hunk update execution out of core/process into a core/install module: they are one feature family about how the binary was installed and how it gets replaced, not about the process a run lives in, and they only landed in process because the startup update notice lived there. The notice stays in core/process as a startup-notice producer built on the app-state file and consumes core/install. Path-only move; no exported symbol changed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sf2y1jWD9fgx7aAKYbLQC6 --- .dependency-cruiser.cjs | 2 +- docs/module-boundaries.md | 9 +++++++++ src/app/cli.ts | 2 +- src/core/{process => install}/installSource.test.ts | 0 src/core/{process => install}/installSource.ts | 0 src/core/{process => install}/latestRelease.test.ts | 0 src/core/{process => install}/latestRelease.ts | 0 src/core/{process => install}/selfUpdate.test.ts | 0 src/core/{process => install}/selfUpdate.ts | 0 src/core/process/updateNotice.ts | 4 ++-- src/core/run/commandInputs.ts | 2 +- src/main.tsx | 2 +- test/cli/update.test.ts | 2 +- 13 files changed, 16 insertions(+), 7 deletions(-) rename src/core/{process => install}/installSource.test.ts (100%) rename src/core/{process => install}/installSource.ts (100%) rename src/core/{process => install}/latestRelease.test.ts (100%) rename src/core/{process => install}/latestRelease.ts (100%) rename src/core/{process => install}/selfUpdate.test.ts (100%) rename src/core/{process => install}/selfUpdate.ts (100%) diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 9d12579a0..c20aae747 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -137,7 +137,7 @@ module.exports = { "core/bootstrap.ts composes the leaves: it names the changeset, the parsed input, the resolved preferences, and the detected theme mode to describe one launch. A module directory importing it back would invert that layering and rebuild the grab-bag cycle the 2026-08 phases dismantled. core/changeset/loaders.ts is the single exception — loadAppBootstrap assembles the value, so it names the shape it returns; its natural home is the app tier, and moving it there retires this exception.", severity: "error", from: { - path: "^src/core/(changeset|run|process|review|vcs|watch|patch|theme)/", + path: "^src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/", pathNot: "^src/core/changeset/loaders\\.ts$", }, to: { path: "^src/core/bootstrap\\.ts$" }, diff --git a/docs/module-boundaries.md b/docs/module-boundaries.md index 732dc52b0..b232bcebe 100644 --- a/docs/module-boundaries.md +++ b/docs/module-boundaries.md @@ -175,6 +175,15 @@ shape it assembles; that function is composition living in the domain tier, and `src/core/` root now holds `bootstrap.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the eight module directories. +Phase 5 (2026-08-18) grouped **how this binary was installed and how it gets replaced** into +`core/install/`: install-source detection (`installSource` — which channel owns the executable), +per-channel release lookup (`latestRelease`), and the `hunk update` execution (`selfUpdate`). +These arrived with the self-update feature as `core/process/` files because the startup update +notice lived there, but they are one feature family about the install lifecycle, not about the +process a run lives in. `process/updateNotice` stays where phase 3 put it — it is a +startup-notice producer built on `appStateFile` — and consumes `core/install` for detection and +release lookup. The move is path-only; no exported symbol changed. + ## Snapshot (2026-08-17, v0.19.0) 331 production modules, 1322 internal edges, **zero boundary violations and zero import diff --git a/src/app/cli.ts b/src/app/cli.ts index 2941a478d..a2c8bdab7 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -18,7 +18,7 @@ import { parseUpdateMethod, parseUpdateVersion, UPDATE_METHOD_VALUES, -} from "../core/process/selfUpdate"; +} from "../core/install/selfUpdate"; import { BUNDLED_SKILL_NAMES, resolveBundledSkillName, diff --git a/src/core/process/installSource.test.ts b/src/core/install/installSource.test.ts similarity index 100% rename from src/core/process/installSource.test.ts rename to src/core/install/installSource.test.ts diff --git a/src/core/process/installSource.ts b/src/core/install/installSource.ts similarity index 100% rename from src/core/process/installSource.ts rename to src/core/install/installSource.ts diff --git a/src/core/process/latestRelease.test.ts b/src/core/install/latestRelease.test.ts similarity index 100% rename from src/core/process/latestRelease.test.ts rename to src/core/install/latestRelease.test.ts diff --git a/src/core/process/latestRelease.ts b/src/core/install/latestRelease.ts similarity index 100% rename from src/core/process/latestRelease.ts rename to src/core/install/latestRelease.ts diff --git a/src/core/process/selfUpdate.test.ts b/src/core/install/selfUpdate.test.ts similarity index 100% rename from src/core/process/selfUpdate.test.ts rename to src/core/install/selfUpdate.test.ts diff --git a/src/core/process/selfUpdate.ts b/src/core/install/selfUpdate.ts similarity index 100% rename from src/core/process/selfUpdate.ts rename to src/core/install/selfUpdate.ts diff --git a/src/core/process/updateNotice.ts b/src/core/process/updateNotice.ts index 6e4f2fcca..4fa908be5 100644 --- a/src/core/process/updateNotice.ts +++ b/src/core/process/updateNotice.ts @@ -1,11 +1,11 @@ import { readAppStateRecord, updateAppStateRecord } from "./appStateFile"; -import { detectInstallSource, type InstallSource } from "./installSource"; +import { detectInstallSource, type InstallSource } from "../install/installSource"; import { type ChannelVersions, fetchChannelVersions, type FetchImpl, type UpdateChannel, -} from "./latestRelease"; +} from "../install/latestRelease"; import { resolveAppStatePath } from "../run/paths"; import type { StartupNotice } from "./startupNotice"; import { diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index 759de339e..300403e69 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -13,7 +13,7 @@ import type { ExtensionVcsShowInput, ExtensionVcsStashShowInput, } from "../../extension-api/types"; -import type { InstallSource } from "../process/installSource"; +import type { InstallSource } from "../install/installSource"; export type LayoutMode = "auto" | "split" | "stack"; export type CursorLine = "row" | "number" | "off"; diff --git a/src/main.tsx b/src/main.tsx index d875a9064..bfec45541 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -57,7 +57,7 @@ async function main() { } if (startupPlan.kind === "self-update") { - const { runSelfUpdateCommand } = await import("./core/process/selfUpdate"); + const { runSelfUpdateCommand } = await import("./core/install/selfUpdate"); process.exit( await runSelfUpdateCommand(startupPlan.input, { stdout: (text) => process.stdout.write(text), diff --git a/test/cli/update.test.ts b/test/cli/update.test.ts index 8d2cd7938..9c372dce2 100644 --- a/test/cli/update.test.ts +++ b/test/cli/update.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { InstallSource } from "../../src/core/process/installSource"; +import type { InstallSource } from "../../src/core/install/installSource"; /** * Runs `hunk update` as a black box with a forced install source.