diff --git a/.changeset/calm-install-aliases.md b/.changeset/calm-install-aliases.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/calm-install-aliases.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/install.sh b/install.sh index 1ad35540d..c6725cdd9 100755 --- a/install.sh +++ b/install.sh @@ -181,16 +181,33 @@ add_hunk_candidate() { [ -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 + hunk_discovered_paths="${hunk_discovered_paths}${hunk_discovered_paths:+ +}${candidate}" + if [ -n "$hunk_candidate_identities" ] && printf '%s\n' "$hunk_candidate_identities" | grep -Fqx "$candidate_identity"; then return 0 fi + hunk_candidate_identities="${hunk_candidate_identities}${hunk_candidate_identities:+ +}${candidate_identity}" hunk_candidates="${hunk_candidates}${hunk_candidates:+ }${candidate}" } +# Prefer a manager-shaped alias for diagnostics while canonical identity owns deduplication. +preferred_manager_path() { + preferred_candidate="$1" + preferred_identity="$(canonical_executable_path "$preferred_candidate")" || preferred_identity="$preferred_candidate" + printf '%s\n' "$hunk_discovered_paths" | while IFS= read -r discovered_candidate; do + discovered_identity="$(canonical_executable_path "$discovered_candidate")" || discovered_identity="$discovered_candidate" + [ "$discovered_identity" = "$preferred_identity" ] || continue + [ "$(competing_install_channel "$discovered_candidate")" = "another package manager" ] && continue + printf '%s\n' "$discovered_candidate" + break + done +} + # Print whether this path wins or loses against the directory this installer manages. shadowing_direction() { - candidate_dir="$(dirname "$1")" + candidate_identity="$(canonical_executable_path "$1")" || candidate_identity="$1" candidate_position=0 target_position=0 position=1 @@ -202,8 +219,9 @@ shadowing_direction() { *) 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 + path_identity="$(canonical_executable_path "${path_dir%/}/hunk")" || path_identity="${path_dir%/}/hunk" + [ "$path_identity" = "$candidate_identity" ] && [ "$candidate_position" -eq 0 ] && candidate_position=$position + [ "$path_identity" = "$target_identity" ] && [ "$target_position" -eq 0 ] && target_position=$position position=$((position + 1)) [ "${last_path_entry:-0}" = "1" ] && break done @@ -260,6 +278,8 @@ competing_install_remediation() { # Refuse to create version skew unless the caller explicitly accepts the competing installs. check_competing_installs() { hunk_candidates="" + hunk_candidate_identities="" + hunk_discovered_paths="" remaining_path=${PATH:-} last_path_entry=0 while :; do @@ -287,10 +307,12 @@ check_competing_installs() { 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 + manager_path="$(preferred_manager_path "$candidate")" + [ -n "$manager_path" ] || manager_path="$candidate" 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")" + warn " ${candidate} ($(competing_install_channel "$manager_path"); version ${candidate_version}; $(shadowing_direction "$candidate"))" + warn " Remove with: $(competing_install_remediation "$manager_path")" done fail "Remove every competing Hunk above, then try again. To knowingly keep them, rerun this installer with --force." } diff --git a/scripts/install-sh.test.ts b/scripts/install-sh.test.ts index 2ae44e0df..3c870b9bd 100644 --- a/scripts/install-sh.test.ts +++ b/scripts/install-sh.test.ts @@ -9,7 +9,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { PLATFORM_PACKAGE_MATRIX } from "./prebuilt-package-helpers"; /** @@ -38,29 +38,65 @@ 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; aliasOnly?: boolean } = {}, + options: { + force?: boolean; + forceEnv?: boolean; + targetFirst?: boolean; + aliasOnly?: boolean; + targetDirectoryAlias?: boolean; + duplicateForeignAlias?: boolean; + nvmAliasFirst?: 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"); + const targetAliasDir = join(root, "target-bin-alias"); + const foreignAliasDir = join(root, "foreign-bin-alias"); mkdirSync(targetDir, { recursive: true }); mkdirSync(foreignDir, { recursive: true }); mkdirSync(inactiveNvmDir, { recursive: true }); writeFakeHunk(join(targetDir, "hunk"), "1.2.3"); + if (options.nvmAliasFirst) { + const npmPackageBinary = join( + home, + ".nvm", + "versions", + "node", + "v20.0.0", + "lib", + "node_modules", + "hunkdiff", + "bin", + "hunk.cjs", + ); + mkdirSync(dirname(npmPackageBinary), { recursive: true }); + writeFakeHunk(npmPackageBinary, "0.8.0"); + symlinkSync("../lib/node_modules/hunkdiff/bin/hunk.cjs", join(inactiveNvmDir, "hunk")); + } else if (!options.aliasOnly) { + writeFakeHunk(join(inactiveNvmDir, "hunk"), "0.8.0"); + } if (options.aliasOnly) { symlinkSync(join(targetDir, "hunk"), join(foreignDir, "hunk")); + } else if (options.nvmAliasFirst) { + symlinkSync(join(inactiveNvmDir, "hunk"), join(foreignDir, "hunk")); } else { writeFakeHunk(join(foreignDir, "hunk"), "0.9.0"); } - if (!options.aliasOnly) writeFakeHunk(join(inactiveNvmDir, "hunk"), "0.8.0"); + if (options.targetDirectoryAlias) symlinkSync(targetDir, targetAliasDir, "dir"); + if (options.duplicateForeignAlias) symlinkSync(foreignDir, foreignAliasDir, "dir"); try { const systemPath = "/usr/local/bin:/usr/bin:/bin"; - const pathEntries = options.targetFirst + let pathEntries = options.targetFirst ? [targetDir, foreignDir, systemPath] : [foreignDir, targetDir, systemPath]; + if (options.targetDirectoryAlias) pathEntries = [targetAliasDir, foreignDir, systemPath]; + if (options.duplicateForeignAlias) { + pathEntries = [foreignDir, foreignAliasDir, targetDir, systemPath]; + } const result = Bun.spawnSync( ["sh", INSTALL_SCRIPT_PATH, ...(options.force ? ["--force"] : [])], { @@ -68,6 +104,7 @@ function runConflictCheck( ...process.env, HOME: home, HUNK_VERSION: "1.2.3", + HUNK_ALLOW_CONFLICTING_INSTALLS: options.forceEnv ? "1" : undefined, PATH: pathEntries.join(":"), }, stdin: "ignore", @@ -83,6 +120,8 @@ function runConflictCheck( foreign: join(foreignDir, "hunk"), inactiveNvm: join(inactiveNvmDir, "hunk"), inactiveNvmNpm: join(inactiveNvmDir, "npm"), + targetAlias: join(targetAliasDir, "hunk"), + foreignAlias: join(foreignAliasDir, "hunk"), }; } finally { rmSync(root, { recursive: true, force: true }); @@ -205,6 +244,41 @@ describe("hunk.dev install script", () => { }, ); + test.skipIf(process.platform === "win32")( + "uses canonical PATH identities for shadowing through a managed-directory alias", + () => { + const result = runConflictCheck({ targetDirectoryAlias: 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")( + "reports one conflict when PATH contains directory aliases to the same foreign install", + () => { + const result = runConflictCheck({ duplicateForeignAlias: true }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr.match(/version 0\.9\.0/g)).toHaveLength(1); + expect(result.stderr).not.toContain(result.foreignAlias); + }, + ); + + test.skipIf(process.platform === "win32")( + "uses a canonical manager path when an unrecognized alias is discovered first", + () => { + const result = runConflictCheck({ nvmAliasFirst: true }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain(`${result.foreign} (npm; version 0.8.0;`); + expect(result.stderr).toContain(`'${result.inactiveNvmNpm}' uninstall -g hunkdiff`); + expect(result.stderr.match(/version 0\.8\.0/g)).toHaveLength(1); + }, + ); + test.skipIf(process.platform === "win32")( "does not treat a PATH symlink to the managed binary as another install", () => { @@ -215,6 +289,17 @@ describe("hunk.dev install script", () => { }, ); + test.skipIf(process.platform === "win32")( + "allows the scripted force environment variable", + () => { + const result = runConflictCheck({ forceEnv: true }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hunk 1.2.3 is already installed."); + expect(result.stderr).toBe(""); + }, + ); + test.skipIf(process.platform === "win32")( "allows an explicit force flag and preserves the already-current fast path", () => {