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..1ad35540d 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,157 @@ 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 # -------------------------------------------------------------------------------------- +# 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_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 + 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' + ;; + */.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' + ;; + 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 +336,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 +347,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 +388,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). @@ -245,12 +400,15 @@ 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")" - 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 +514,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..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 { 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"; @@ -22,6 +30,65 @@ 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; aliasOnly?: 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"); + 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, systemPath] + : [foreignDir, targetDir, systemPath]; + 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 +175,58 @@ 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")( + "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", + () => { + 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 ```