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/.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/.github/workflows/install-sh-e2e.yml b/.github/workflows/install-sh-e2e.yml new file mode 100644 index 000000000..52074ef5f --- /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: + - 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 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 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 + env: + HUNK_VERSION: ${{ steps.release.outputs.version }} + run: | + set -eu + sandbox="$(mktemp -d)" + custom="${sandbox}/hunk tools" + + 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; } + "$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 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" 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/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/install.sh b/install.sh new file mode 100755 index 000000000..48ab99cb3 --- /dev/null +++ b/install.sh @@ -0,0 +1,372 @@ +#!/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`. +# +# 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. + +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 +} + +# -------------------------------------------------------------------------------------- +# 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 +} + +# -------------------------------------------------------------------------------------- +# Downloading +# -------------------------------------------------------------------------------------- + +# 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 +} + +# -------------------------------------------------------------------------------------- +# 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 +} + +# -------------------------------------------------------------------------------------- +# PATH helpers +# -------------------------------------------------------------------------------------- + +# 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" + # 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 + + 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}." +} + +# 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" + for candidate in "$@"; do + if [ -f "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + printf '%s\n' "$fallback" +} + +# -------------------------------------------------------------------------------------- +# 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() { + # 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. + 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}" + + 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)." + 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 ${quoted_bin_dir}" + ;; + *) + add_path_line "${home_dir}/.profile" "$path_line" + ;; + esac + info "Restart your shell, or run: export PATH=${quoted_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 +} + +main "$@" 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 new file mode 100644 index 000000000..92bf58bc7 --- /dev/null +++ b/scripts/install-sh.test.ts @@ -0,0 +1,157 @@ +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 `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 + * 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, "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 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 [definitionsBody] = INSTALL_SCRIPT.split("main() {"); + + 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 function definitions, without the main invocation. + definitionsBody ?? "", + 'os="$(detect_os)"', + 'arch="$(detect_arch)"', + '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.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"); + }); + + 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/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/src/app/cli.ts b/src/app/cli.ts index faaa493f4..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, @@ -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/install/installSource.test.ts similarity index 80% rename from src/core/process/installSource.test.ts rename to src/core/install/installSource.test.ts index f8a421f3d..b5f30f99f 100644 --- a/src/core/process/installSource.test.ts +++ b/src/core/install/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/install/installSource.ts similarity index 84% rename from src/core/process/installSource.ts rename to src/core/install/installSource.ts index de830ce14..2ed3237fb 100644 --- a/src/core/process/installSource.ts +++ b/src/core/install/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,16 @@ export function detectInstallSource(facts: InstallSourceFacts = {}): InstallSour return "homebrew"; } + if (isCurlInstalledExecutablePath(executablePath)) { + return "curl"; + } + + // 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/latestRelease.test.ts b/src/core/install/latestRelease.test.ts similarity index 74% rename from src/core/process/latestRelease.test.ts rename to src/core/install/latestRelease.test.ts index 16a52a4e8..d87124352 100644 --- a/src/core/process/latestRelease.test.ts +++ b/src/core/install/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/install/latestRelease.ts similarity index 72% rename from src/core/process/latestRelease.ts rename to src/core/install/latestRelease.ts index c02b2a35c..74d93a09a 100644 --- a/src/core/process/latestRelease.ts +++ b/src/core/install/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/install/selfUpdate.test.ts similarity index 76% rename from src/core/process/selfUpdate.test.ts rename to src/core/install/selfUpdate.test.ts index 2efd96bd5..a2dfc9ee5 100644 --- a/src/core/process/selfUpdate.test.ts +++ b/src/core/install/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,33 +34,55 @@ 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, + }; } +/** + * 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", () => { expect(parseUpdateMethod("brew")).toBe("homebrew"); @@ -66,8 +90,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 +195,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_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([ + { 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_UPDATE_PIPELINE]]); + 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/install/selfUpdate.ts similarity index 66% rename from src/core/process/selfUpdate.ts rename to src/core/install/selfUpdate.ts index 194398c0e..79ea4ef7c 100644 --- a/src/core/process/selfUpdate.ts +++ b/src/core/install/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,56 @@ 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 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 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. */ +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 +213,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 +304,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 +344,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..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 { @@ -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/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 3fc53cadc..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. @@ -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..b1a47230c 100644 --- a/vercel.json +++ b/vercel.json @@ -1,8 +1,15 @@ { "$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" + "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/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..a7b19c0da 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` 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. ## 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/).