From aae75bfc20461d6f50f550b27c4b5ef36996d10f Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 31 Aug 2026 15:28:52 -0400 Subject: [PATCH 1/2] fix(install): refuse conflicting Hunk installs --- .changeset/quiet-install-conflicts.md | 5 + install.sh | 141 +++++++++++++++++- scripts/install-sh.test.ts | 96 +++++++++++- .../src/content/docs/docs/start/install.md | 18 ++- 4 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 .changeset/quiet-install-conflicts.md diff --git a/.changeset/quiet-install-conflicts.md b/.changeset/quiet-install-conflicts.md new file mode 100644 index 000000000..cee523c85 --- /dev/null +++ b/.changeset/quiet-install-conflicts.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Refuse curl installs alongside competing Hunk binaries unless explicitly forced, and print each conflicting path, version, PATH precedence, and package-manager removal guidance. diff --git a/install.sh b/install.sh index 48ab99cb3..4a957864e 100755 --- a/install.sh +++ b/install.sh @@ -10,11 +10,14 @@ # 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 +# curl -fsSL https://hunk.dev/install.sh | sh -s -- --force # # 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 +# HUNK_ALLOW_CONFLICTING_INSTALLS +# set to 1 to install alongside another Hunk # # macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. # @@ -59,12 +62,15 @@ Arguments: Options: --no-modify-path do not add the install directory to your shell startup files + -f, --force install alongside competing Hunk installs -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 + HUNK_ALLOW_CONFLICTING_INSTALLS + set to 1 for --force macOS and Linux only. On Windows, install with `npm install -g hunkdiff`. EOF @@ -138,13 +144,129 @@ fetch() { # 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 + candidate_version="$("$1" --version 2>/dev/null | tr -d 'v \t\r' | head -n 1)" + case "$candidate_version" in + [0-9]* ) printf '%s\n' "$candidate_version" ;; + esac } # -------------------------------------------------------------------------------------- -# PATH helpers +# Competing-install and PATH helpers # -------------------------------------------------------------------------------------- +# Add one executable path to the newline-delimited conflict list exactly once. +add_hunk_candidate() { + candidate="$1" + [ -x "$candidate" ] || return 0 + [ "$candidate" = "$target_binary" ] && return 0 + if [ -n "$hunk_candidates" ] && printf '%s\n' "$hunk_candidates" | grep -Fqx "$candidate"; then + return 0 + fi + hunk_candidates="${hunk_candidates}${hunk_candidates:+ +}${candidate}" +} + +# Print whether this path wins or loses against the directory this installer manages. +shadowing_direction() { + candidate_dir="$(dirname "$1")" + candidate_position=0 + target_position=0 + position=1 + remaining_path=${PATH:-} + last_path_entry=0 + while :; do + case "$remaining_path" in + *:*) path_dir=${remaining_path%%:*}; remaining_path=${remaining_path#*:} ;; + *) path_dir=$remaining_path; remaining_path=""; last_path_entry=1 ;; + esac + [ -n "$path_dir" ] || path_dir=. + [ "$path_dir" = "$candidate_dir" ] && [ "$candidate_position" -eq 0 ] && candidate_position=$position + [ "$path_dir" = "$bin_dir" ] && [ "$target_position" -eq 0 ] && target_position=$position + position=$((position + 1)) + [ "${last_path_entry:-0}" = "1" ] && break + done + + if [ "$candidate_position" -eq 0 ]; then + printf 'not on the current PATH' + elif [ "$target_position" -eq 0 ] || [ "$candidate_position" -lt "$target_position" ]; then + printf 'shadows %s' "$target_binary" + else + printf 'is shadowed by %s' "$target_binary" + fi +} + +# Name the likely owner from stable install-layout signals. +competing_install_channel() { + case "$1" in + */.nvm/versions/node/*/bin/hunk | */node_modules/* | */.npm/*) printf 'npm' ;; + /opt/homebrew/bin/hunk | /usr/local/bin/hunk | /home/linuxbrew/.linuxbrew/bin/hunk) printf 'Homebrew or npm' ;; + */mise/installs/*/hunk) printf 'mise' ;; + */.bun/bin/hunk) printf 'Bun' ;; + */pnpm/*) printf 'pnpm' ;; + *) printf 'another package manager' ;; + esac +} + +# Print package-manager-specific removal guidance without deleting anything. +competing_install_remediation() { + candidate="$1" + case "$candidate" in + */.nvm/versions/node/*/bin/hunk) + printf "'%s/npm' uninstall -g hunkdiff" "$(dirname "$candidate")" + ;; + /opt/homebrew/bin/hunk | /usr/local/bin/hunk | /home/linuxbrew/.linuxbrew/bin/hunk) + printf 'brew uninstall hunk, or npm uninstall -g hunkdiff if npm owns this path' + ;; + */mise/installs/*/hunk) + printf 'mise uninstall hunk' + ;; + */node_modules/* | */.npm/* | */.bun/bin/hunk | */pnpm/*) + printf 'npm uninstall -g hunkdiff (using the package manager and runtime that own this path)' + ;; + *) + printf 'remove this Hunk with the package manager that installed it' + ;; + esac +} + +# Refuse to create version skew unless the caller explicitly accepts the competing installs. +check_competing_installs() { + hunk_candidates="" + remaining_path=${PATH:-} + last_path_entry=0 + while :; do + case "$remaining_path" in + *:*) path_dir=${remaining_path%%:*}; remaining_path=${remaining_path#*:} ;; + *) path_dir=$remaining_path; remaining_path=""; last_path_entry=1 ;; + esac + [ -n "$path_dir" ] || path_dir=. + add_hunk_candidate "${path_dir%/}/hunk" + [ "$last_path_entry" = "1" ] && break + done + + # nvm globals are per Node version, so inactive versions can be absent from PATH while old + # terminal panes still resolve them. These globs intentionally need no npm/node executable. + if [ -n "$home_dir" ]; then + for candidate in "$home_dir"/.nvm/versions/node/*/bin/hunk \ + "$home_dir"/.local/share/mise/installs/hunk/*/hunk \ + "$home_dir"/.local/share/mise/installs/hunk/*/bin/hunk; do + add_hunk_candidate "$candidate" + done + fi + + [ -n "$hunk_candidates" ] || return 0 + [ "$allow_conflicts" = "1" ] && return 0 + + warn "Another Hunk installation already exists; this installer will not overwrite or remove it." + printf '%s\n' "$hunk_candidates" | while IFS= read -r candidate; do + candidate_version="$(installed_version "$candidate")" + [ -n "$candidate_version" ] || candidate_version="unknown" + warn " ${candidate} ($(competing_install_channel "$candidate"); version ${candidate_version}; $(shadowing_direction "$candidate"))" + warn " Remove with: $(competing_install_remediation "$candidate")" + done + fail "Remove every competing Hunk above, then try again. To knowingly keep them, rerun this installer with --force." +} + # Append one line to one file unless an equivalent line is already there. Prints what it did. add_path_line() { rc_file="$1" @@ -186,6 +308,7 @@ first_existing() { main() { version="${HUNK_VERSION:-}" no_modify_path="${HUNK_NO_MODIFY_PATH:-0}" + allow_conflicts="${HUNK_ALLOW_CONFLICTING_INSTALLS:-0}" while [ "$#" -gt 0 ]; do case "$1" in @@ -196,6 +319,9 @@ main() { --no-modify-path) no_modify_path=1 ;; + -f | --force) + allow_conflicts=1 + ;; -*) fail "Unknown option: $1 (run with --help to see the supported options)" ;; @@ -234,7 +360,8 @@ main() { custom_dir="" if [ -n "${HUNK_INSTALL_DIR:-}" ]; then - bin_dir="$HUNK_INSTALL_DIR" + bin_dir="${HUNK_INSTALL_DIR%/}" + [ -n "$bin_dir" ] || bin_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). @@ -246,11 +373,9 @@ main() { fi target_binary="${bin_dir}/hunk" + check_competing_installs 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." @@ -356,7 +481,9 @@ main() { add_path_line "${home_dir}/.profile" "$path_line" ;; esac - info "Restart your shell, or run: export PATH=${quoted_bin_dir}:\"\$PATH\"" + info "" + info "IMPORTANT: Restart every open shell and terminal pane, or run this in each one:" + info " export PATH=${quoted_bin_dir}:\"\$PATH\"" fi info "" diff --git a/scripts/install-sh.test.ts b/scripts/install-sh.test.ts index 92bf58bc7..1aa05fd50 100644 --- a/scripts/install-sh.test.ts +++ b/scripts/install-sh.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, 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"; @@ -22,6 +22,58 @@ 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"); +/** Write a minimal Hunk executable that reports one version. */ +function writeFakeHunk(path: string, version: string) { + writeFileSync(path, `#!/bin/sh\nprintf '${version}\\n'\n`); + chmodSync(path, 0o755); +} + +/** Run the installer against an already-current managed target without downloading anything. */ +function runConflictCheck(options: { force?: boolean; targetFirst?: boolean } = {}) { + const root = mkdtempSync(join(tmpdir(), "hunk-install-conflict-")); + const home = join(root, "home"); + const targetDir = join(home, ".hunk", "bin"); + const foreignDir = join(root, "foreign", "bin"); + const inactiveNvmDir = join(home, ".nvm", "versions", "node", "v20.0.0", "bin"); + mkdirSync(targetDir, { recursive: true }); + mkdirSync(foreignDir, { recursive: true }); + mkdirSync(inactiveNvmDir, { recursive: true }); + writeFakeHunk(join(targetDir, "hunk"), "1.2.3"); + writeFakeHunk(join(foreignDir, "hunk"), "0.9.0"); + writeFakeHunk(join(inactiveNvmDir, "hunk"), "0.8.0"); + + try { + const pathEntries = options.targetFirst + ? [targetDir, foreignDir, process.env.PATH ?? ""] + : [foreignDir, targetDir, process.env.PATH ?? ""]; + const result = Bun.spawnSync( + ["sh", INSTALL_SCRIPT_PATH, ...(options.force ? ["--force"] : [])], + { + env: { + ...process.env, + HOME: home, + HUNK_VERSION: "1.2.3", + PATH: pathEntries.join(":"), + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); + return { + exitCode: result.exitCode, + stdout: Buffer.from(result.stdout).toString("utf8"), + stderr: Buffer.from(result.stderr).toString("utf8"), + target: join(targetDir, "hunk"), + foreign: join(foreignDir, "hunk"), + inactiveNvm: join(inactiveNvmDir, "hunk"), + inactiveNvmNpm: join(inactiveNvmDir, "npm"), + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + /** * Run the installer's platform detection with a stubbed `uname` and print ` `. * @@ -108,6 +160,48 @@ describe("hunk.dev install script", () => { expect(INSTALL_SCRIPT).toContain("npm install -g hunkdiff"); }); + test.skipIf(process.platform === "win32")( + "refuses every visible and inactive-nvm competing install with exact remediation", + () => { + const result = runConflictCheck(); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + `${result.foreign} (another package manager; version 0.9.0; shadows ${result.target})`, + ); + expect(result.stderr).toContain( + `${result.inactiveNvm} (npm; version 0.8.0; not on the current PATH)`, + ); + expect(result.stderr).toContain(`'${result.inactiveNvmNpm}' uninstall -g hunkdiff`); + expect(result.stderr).toContain("rerun this installer with --force"); + expect(result.stdout).not.toContain("Downloading"); + }, + ); + + test.skipIf(process.platform === "win32")( + "names when the managed target shadows a competing install", + () => { + const result = runConflictCheck({ targetFirst: true }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + `${result.foreign} (another package manager; version 0.9.0; is shadowed by ${result.target})`, + ); + }, + ); + + test.skipIf(process.platform === "win32")( + "allows an explicit force flag and preserves the already-current fast path", + () => { + const result = runConflictCheck({ force: true }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hunk 1.2.3 is already installed."); + expect(result.stdout).not.toContain("Downloading"); + expect(result.stderr).toBe(""); + }, + ); + test.skipIf(process.platform === "win32")( "resolves every published macOS and Linux platform pair", () => { diff --git a/website/src/content/docs/docs/start/install.md b/website/src/content/docs/docs/start/install.md index 038a1dfcc..5bea69b87 100644 --- a/website/src/content/docs/docs/start/install.md +++ b/website/src/content/docs/docs/start/install.md @@ -16,17 +16,23 @@ hunk --version When the release publishes `SHA256SUMS` and your machine has `sha256sum` or `shasum`, the script verifies the downloaded archive before installing. Otherwise it warns that verification was skipped and continues. It 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: +The script accepts these 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. | +| 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. | +| `--force` (or `HUNK_ALLOW_CONFLICTING_INSTALLS=1`) | Install despite another Hunk on PATH or in a known version-manager directory. | + +By default, the installer refuses to create a second Hunk installation. It lists every competing +path it finds, its version and PATH precedence, and the command that removes it. Remove those +installs first; use `--force` only when you deliberately manage multiple copies. ```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 | sh -s -- --force curl -fsSL https://hunk.dev/install.sh | HUNK_VERSION=0.19.0 sh ``` From 3737f976bf2b62d2eb2f72e26fe8aa7dba246fe8 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 31 Aug 2026 15:46:27 -0400 Subject: [PATCH 2/2] fix(install): normalize competing binary paths --- install.sh | 41 ++++++++++++++++++++++++++++++++++---- scripts/install-sh.test.ts | 37 ++++++++++++++++++++++++++++------ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/install.sh b/install.sh index 4a957864e..1ad35540d 100755 --- a/install.sh +++ b/install.sh @@ -154,11 +154,33 @@ installed_version() { # Competing-install and PATH helpers # -------------------------------------------------------------------------------------- +# Resolve directory aliases and one executable symlink without requiring non-POSIX `readlink -f`. +canonical_executable_path() { + canonical_input="$1" + canonical_depth="${2:-0}" + canonical_dir="$(dirname "$canonical_input")" + canonical_name="$(basename "$canonical_input")" + physical_dir="$(CDPATH='' cd "$canonical_dir" 2>/dev/null && pwd -P)" || return 1 + canonical_path="${physical_dir%/}/${canonical_name}" + if command -v readlink >/dev/null 2>&1; then + link_target="$(readlink "$canonical_path" 2>/dev/null)" || link_target="" + if [ -n "$link_target" ] && [ "$canonical_depth" -lt 8 ]; then + canonical_depth=$((canonical_depth + 1)) + case "$link_target" in + /*) canonical_executable_path "$link_target" "$canonical_depth"; return ;; + *) canonical_executable_path "${physical_dir}/${link_target}" "$canonical_depth"; return ;; + esac + fi + fi + printf '%s\n' "$canonical_path" +} + # Add one executable path to the newline-delimited conflict list exactly once. add_hunk_candidate() { candidate="$1" [ -x "$candidate" ] || return 0 - [ "$candidate" = "$target_binary" ] && return 0 + candidate_identity="$(canonical_executable_path "$candidate")" || candidate_identity="$candidate" + [ "$candidate_identity" = "$target_identity" ] && return 0 if [ -n "$hunk_candidates" ] && printf '%s\n' "$hunk_candidates" | grep -Fqx "$candidate"; then return 0 fi @@ -220,8 +242,14 @@ competing_install_remediation() { */mise/installs/*/hunk) printf 'mise uninstall hunk' ;; - */node_modules/* | */.npm/* | */.bun/bin/hunk | */pnpm/*) - printf 'npm uninstall -g hunkdiff (using the package manager and runtime that own this path)' + */.bun/bin/hunk) + printf 'bun remove --global hunkdiff' + ;; + */pnpm/*) + printf 'pnpm remove --global hunkdiff' + ;; + */node_modules/* | */.npm/*) + printf 'npm uninstall -g hunkdiff (using the npm runtime that owns this path)' ;; *) printf 'remove this Hunk with the package manager that installed it' @@ -372,7 +400,12 @@ main() { bin_dir="${payload_dir}/bin" fi - target_binary="${bin_dir}/hunk" + if [ "$bin_dir" = "/" ]; then + target_binary="/hunk" + else + target_binary="${bin_dir}/hunk" + fi + target_identity="$(canonical_executable_path "$target_binary")" || target_identity="$target_binary" check_competing_installs current="$(installed_version "$target_binary")" diff --git a/scripts/install-sh.test.ts b/scripts/install-sh.test.ts index 1aa05fd50..2ae44e0df 100644 --- a/scripts/install-sh.test.ts +++ b/scripts/install-sh.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { PLATFORM_PACKAGE_MATRIX } from "./prebuilt-package-helpers"; @@ -29,7 +37,9 @@ function writeFakeHunk(path: string, version: string) { } /** Run the installer against an already-current managed target without downloading anything. */ -function runConflictCheck(options: { force?: boolean; targetFirst?: boolean } = {}) { +function runConflictCheck( + options: { force?: boolean; targetFirst?: boolean; aliasOnly?: boolean } = {}, +) { const root = mkdtempSync(join(tmpdir(), "hunk-install-conflict-")); const home = join(root, "home"); const targetDir = join(home, ".hunk", "bin"); @@ -39,13 +49,18 @@ function runConflictCheck(options: { force?: boolean; targetFirst?: boolean } = mkdirSync(foreignDir, { recursive: true }); mkdirSync(inactiveNvmDir, { recursive: true }); writeFakeHunk(join(targetDir, "hunk"), "1.2.3"); - writeFakeHunk(join(foreignDir, "hunk"), "0.9.0"); - writeFakeHunk(join(inactiveNvmDir, "hunk"), "0.8.0"); + if (options.aliasOnly) { + symlinkSync(join(targetDir, "hunk"), join(foreignDir, "hunk")); + } else { + writeFakeHunk(join(foreignDir, "hunk"), "0.9.0"); + } + if (!options.aliasOnly) writeFakeHunk(join(inactiveNvmDir, "hunk"), "0.8.0"); try { + const systemPath = "/usr/local/bin:/usr/bin:/bin"; const pathEntries = options.targetFirst - ? [targetDir, foreignDir, process.env.PATH ?? ""] - : [foreignDir, targetDir, process.env.PATH ?? ""]; + ? [targetDir, foreignDir, systemPath] + : [foreignDir, targetDir, systemPath]; const result = Bun.spawnSync( ["sh", INSTALL_SCRIPT_PATH, ...(options.force ? ["--force"] : [])], { @@ -190,6 +205,16 @@ describe("hunk.dev install script", () => { }, ); + test.skipIf(process.platform === "win32")( + "does not treat a PATH symlink to the managed binary as another install", + () => { + const result = runConflictCheck({ aliasOnly: true }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hunk 1.2.3 is already installed."); + }, + ); + test.skipIf(process.platform === "win32")( "allows an explicit force flag and preserves the already-current fast path", () => {