diff --git a/README.md b/README.md index 313cac85b9..7ff1253646 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ On Windows, install the same self-contained binary (to `%USERPROFILE%\.altimate\ powershell -c "irm https://www.altimate.sh/install.ps1 | iex" ``` -The standalone install drops a single self-contained binary named `altimate`. The npm install exposes both `altimate` and `altimate-code` on PATH; the standalone install only exposes `altimate`. Alpine Linux (musl) and Windows on ARM64 are not currently supported by the standalone binary — use `apk add gcompat` on Alpine, or use WSL on Windows-on-ARM. +The standalone install drops a single self-contained binary named `altimate`, plus an `altimate-code` alias for it — the same two names the npm install exposes. On Windows that alias is an `altimate-code.cmd` shim, which passes its arguments through `cmd.exe` (`%VAR%` expands, unquoted `&` `|` `^` are interpreted), so scripts should call `altimate`. Alpine Linux (musl) and Windows on ARM64 are not currently supported by the standalone binary — use `apk add gcompat` on Alpine, or use WSL on Windows-on-ARM. For GitHub, [install the Altimate Code App](https://github.com/apps/altimate-code-agent/installations/new) to select repositories for interactive agent tasks. Automatic dbt pull-request diff --git a/docs/docs/reference/troubleshooting.md b/docs/docs/reference/troubleshooting.md index 1b9cc07500..587f5e487c 100644 --- a/docs/docs/reference/troubleshooting.md +++ b/docs/docs/reference/troubleshooting.md @@ -10,7 +10,7 @@ description: "Fix common Altimate Code installation and runtime issues including **Symptoms:** `altimate-code: command not found` after running `curl -fsSL https://www.altimate.sh/install | bash`. -As of v0.7.1 the curl-installed binary is named `altimate`, not `altimate-code`. The npm package continues to ship both names. If you scripted against the curl install: +As of v0.7.1 the curl-installed binary is named `altimate`, and curl installs from v0.7.1 until the `altimate-code` alias was added put only that name on PATH (v0.7.0 and earlier installed `altimate-code` under `~/.altimate-code/bin` instead). Re-run the installer — it adds `altimate-code` next to `altimate` in `~/.altimate/bin` — or call `altimate` directly: ```bash # Before (v0.7.0 and earlier curl install): @@ -20,8 +20,6 @@ As of v0.7.1 the curl-installed binary is named `altimate`, not `altimate-code`. ~/.altimate/bin/altimate run "..." ``` -Or stay on the `altimate-code` name by installing via npm: `npm install -g altimate-code`. - ### `Cannot find module '@altimateai/altimate-code-core'` **Symptoms:** Binary crashes on first run with the above error (curl-installed v0.7.0 and earlier). diff --git a/docs/docs/reference/windows-wsl.md b/docs/docs/reference/windows-wsl.md index 83c63f44a8..890e62fccf 100644 --- a/docs/docs/reference/windows-wsl.md +++ b/docs/docs/reference/windows-wsl.md @@ -19,7 +19,11 @@ powershell -c "irm https://www.altimate.sh/install.ps1 | iex" ``` This downloads `altimate.exe` to `%USERPROFILE%\.altimate\bin` and adds that -directory to your user PATH (open a new terminal afterwards). The installer +directory to your user PATH (open a new terminal afterwards). It also writes an +`altimate-code` alias there: `altimate-code.cmd` for cmd.exe and PowerShell, and +an extensionless shim for Git Bash. Inside WSL, install the Linux build with +`curl -fsSL https://www.altimate.sh/install | bash`; the Windows shim refuses to +run there. The installer auto-detects AVX2 support and falls back to the baseline build on older CPUs. Options (pass via a script block): diff --git a/install b/install index 5698bddde8..13fe0d1630 100755 --- a/install +++ b/install @@ -275,6 +275,14 @@ check_version() { # treat an empty==empty as "already installed" — fall through and reinstall. if [ -n "$specific_version" ] && [[ "$installed_version" == "$specific_version" ]]; then print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed${NC}" + # A standalone install from before the alias existed still gets it, but only + # when the matching binary is the one in INSTALL_DIR, so the alias never runs a + # different version than the one just checked. + # -ef compares the directories themselves, so a PATH entry spelled differently + # (a symlinked HOME, a trailing slash) still matches. + if [ "$(dirname "$(command -v "$probe")")" -ef "$INSTALL_DIR" ]; then + link_alias "$binary_name" + fi exit 0 elif [ -n "$installed_version" ]; then print_message info "${MUTED}Installed version: ${NC}$installed_version." @@ -453,18 +461,81 @@ download_and_install() { mv "$tmp_dir/$binary_name" "${INSTALL_DIR}/$binary_name" chmod 755 "${INSTALL_DIR}/$binary_name" - # If the previous install dropped `altimate-code` here (pre-v0.7.1 curl - # install), remove it so users don't end up running a stale binary by - # name. The npm/Homebrew/AUR paths still expose both names; only the - # standalone $INSTALL_DIR has the rename collapse. - if [ "$os" != "windows" ] && [ -f "${INSTALL_DIR}/altimate-code" ]; then - rm -f "${INSTALL_DIR}/altimate-code" - print_message info "${MUTED}Removed stale ${NC}altimate-code${MUTED} from ${NC}$INSTALL_DIR${MUTED} (renamed to altimate in v0.7.1)${NC}" - fi + link_alias "$binary_name" rm -rf "$tmp_dir" } +# Expose `altimate-code` next to the installed binary, matching npm, Homebrew and +# AUR, which all ship both names. A relative symlink keeps following the binary +# across upgrades. `ln -sfn` also replaces a pre-v0.7.1 standalone `altimate-code` +# binary, so that name can never run a stale build, and replaces a link to a +# directory instead of writing into that directory. Under Git Bash, MSYS and +# Cygwin, where `ln -s` usually makes a full copy, the shims install.ps1 writes +# are written for altimate.exe, so both Windows installers leave the same layout. +# A `--binary` named plain `altimate` there only runs from those shells, so it +# gets the same link (or copy) as on POSIX. +link_alias() { + local target="$1" + local alias_name + case "$target" in + altimate) alias_name="altimate-code" ;; + altimate.exe) alias_name="altimate-code.exe" ;; + *) return 0 ;; + esac + [ -f "${INSTALL_DIR}/$target" ] || return 0 + + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + if [ "$target" = "altimate.exe" ]; then + write_windows_shims + return 0 + fi + ;; + esac + + if [ -d "${INSTALL_DIR}/$alias_name" ] && [ ! -L "${INSTALL_DIR}/$alias_name" ]; then + print_message warning "${MUTED}Skipped the ${NC}$alias_name${MUTED} alias: ${NC}${INSTALL_DIR}/$alias_name${MUTED} is a directory${NC}" + return 0 + fi + + if ! (cd "$INSTALL_DIR" && ln -sfn "$target" "$alias_name"); then + print_message warning "${MUTED}Could not create the ${NC}$alias_name${MUTED} alias in ${NC}$INSTALL_DIR" + fi +} + +# The two shims Install-Alias in install.ps1 writes, byte for byte; keep both +# copies in step. The .cmd first, and a stray altimate-code.exe (which PATHEXT +# would put in front of it, and which older Git Bash installs left as a full copy) +# goes only once it exists. The sh shim only runs under Git Bash, MSYS or Cygwin: +# WSL also sees this directory on PATH, and there it must not pass for a Linux +# install of the same version. +write_windows_shims() { + local exe="${INSTALL_DIR}/altimate-code.exe" + rm -f "$exe.old" 2>/dev/null || true + write_shim altimate-code.cmd '@echo off\r\n"%%~dp0altimate.exe" %%*\r\nexit /b %%ERRORLEVEL%%\r\n' || return 0 + if [ -f "$exe" ] || [ -L "$exe" ]; then + rm -f "$exe" 2>/dev/null || mv -f "$exe" "$exe.old" 2>/dev/null || + print_message warning "${MUTED}Could not remove ${NC}$exe${MUTED}, which shadows the altimate-code.cmd alias${NC}" + fi + write_shim altimate-code '#!/bin/sh\ncase "$(uname -s)" in\n MINGW*|MSYS*|CYGWIN*) exec "$(dirname "$0")/altimate.exe" "$@" ;;\nesac\necho "altimate-code: this is the Windows build; it runs only from Git Bash, MSYS or Cygwin" >&2\nexit 127\n' || return 0 +} + +# Writes printf format $2 to $INSTALL_DIR/$1 as an executable file (Cygwin keeps +# POSIX modes, so the sh shim needs the bit), replacing a file or link there +# instead of writing through it. A real directory is left alone. +write_shim() { + local path="${INSTALL_DIR}/$1" + if [ -d "$path" ] && [ ! -L "$path" ]; then + print_message warning "${MUTED}Skipped the ${NC}$1${MUTED} alias: ${NC}$path${MUTED} is a directory${NC}" + return 1 + fi + if ! { rm -f "$path" && printf "$2" > "$path" && chmod 755 "$path"; } 2>/dev/null; then + print_message warning "${MUTED}Could not create the ${NC}$1${MUTED} alias in ${NC}$INSTALL_DIR" + return 1 + fi +} + install_from_binary() { # --binary may point to either altimate or altimate.exe; preserve the # caller's basename so the installed file matches what was supplied. @@ -479,12 +550,14 @@ install_from_binary() { if [ -f "$dest_path" ] && [ "$binary_path" -ef "$dest_path" ]; then print_message info "${MUTED}Source and destination are the same file — nothing to do: ${NC}$dest_path" chmod 755 "$dest_path" + link_alias "$dest_name" return 0 fi print_message info "\n${MUTED}Installing ${NC}$dest_name ${MUTED}from: ${NC}$binary_path" cp "$binary_path" "$dest_path" chmod 755 "$dest_path" + link_alias "$dest_name" } # Write the same post-install marker that npm's postinstall.mjs writes, so the diff --git a/install.ps1 b/install.ps1 index 73efc64d58..9ec1413bdb 100644 --- a/install.ps1 +++ b/install.ps1 @@ -40,6 +40,85 @@ $InstalledBinary = Join-Path $InstallDir $BinaryName function Write-Muted($msg) { Write-Host $msg -ForegroundColor DarkGray } function Write-Err($msg) { Write-Host "error: $msg" -ForegroundColor Red } +# Expose `altimate-code` next to altimate.exe, matching npm, Homebrew and AUR, +# which all ship both names. Shims instead of a copy or link: they follow the +# binary across upgrades (which rename the running exe aside) and need no symlink +# privilege. cmd.exe and PowerShell resolve the .cmd; Git Bash and other MSYS +# shells only resolve the extensionless script. The bash installer writes the +# same two shims under Git Bash (write_windows_shims); keep both copies in step. +# npm also writes a .ps1 shim, left out here because an execution policy that +# blocks scripts would break the name. Like every .cmd shim, the .cmd hands its +# arguments to cmd.exe, which expands %VAR% and interprets unquoted & | < > ^; +# scripts should call `altimate`. A stray altimate-code.exe would win over the +# .cmd through PATHEXT order and could run a stale build, so it is moved out of +# the way. +# +# Best-effort: a failure here warns and never aborts the install. +function Install-Alias { + param([string]$Dir) + + try { + if (-not (Test-Path -LiteralPath ([IO.Path]::Combine($Dir, "altimate.exe")) -PathType Leaf)) { return } + + $shadowingExe = [IO.Path]::Combine($Dir, "altimate-code.exe") + Remove-Item -LiteralPath "$shadowingExe.old" -Force -ErrorAction SilentlyContinue + + # Replaces an existing file or link instead of writing through it, so a link at the + # shim's path can never overwrite the file it points to; GetAttributes does not follow + # links, so a dangling one is caught too. The old entry is deleted before the write, + # so a failed write leaves no shim of that name. A real directory is left alone and + # the write throws into the catch. + $writeShim = { + param([string]$Name, [string]$Text) + $path = [IO.Path]::Combine($Dir, $Name) + $attrs = $null + try { $attrs = [IO.File]::GetAttributes($path) } catch {} + if ($null -ne $attrs) { + $isDir = [bool]($attrs -band [IO.FileAttributes]::Directory) + if ($isDir -and ($attrs -band [IO.FileAttributes]::ReparsePoint)) { [IO.Directory]::Delete($path) } + elseif (-not $isDir) { [IO.File]::Delete($path) } + } + [IO.File]::WriteAllText($path, $Text, [Text.Encoding]::ASCII) + } + + # The .cmd first, and the exe that would shadow it goes only once it exists: if it + # cannot be written, an existing altimate-code.exe stays usable. `altimate-code + # upgrade` run through the .cmd rewrites it while cmd.exe is still reading it by byte + # offset, which is safe only while its content stays byte-identical. + & $writeShim "altimate-code.cmd" "@echo off`r`n`"%~dp0altimate.exe`" %*`r`nexit /b %ERRORLEVEL%`r`n" + + if (Test-Path -LiteralPath $shadowingExe -PathType Leaf) { + try { Remove-Item -LiteralPath $shadowingExe -Force } catch { + Remove-Item -LiteralPath "$shadowingExe.old" -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $shadowingExe -Destination "$shadowingExe.old" -Force + } + } + + # Only under Git Bash, MSYS or Cygwin: WSL also sees this directory on PATH, and there + # the shim must not pass for a Linux install of the same version. + & $writeShim "altimate-code" ((@( + '#!/bin/sh' + 'case "$(uname -s)" in' + ' MINGW*|MSYS*|CYGWIN*) exec "$(dirname "$0")/altimate.exe" "$@" ;;' + 'esac' + 'echo "altimate-code: this is the Windows build; it runs only from Git Bash, MSYS or Cygwin" >&2' + 'exit 127' + ) -join "`n") + "`n") + } catch { + Write-Muted "Could not create the altimate-code alias in ${Dir}: $($_.Exception.Message)" + } +} + +# Whether $Path names a file directly in $Dir. Compared as full paths, ignoring case and +# a trailing separator, since a PATH entry can spell the directory differently. +function Test-PathInDir { + param([string]$Path, [string]$Dir) + + $separators = [char[]]@('\', '/') + $parent = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Path)).TrimEnd($separators) + return $parent -eq [IO.Path]::GetFullPath($Dir).TrimEnd($separators) +} + function Show-Usage { Write-Host @" Altimate Code Installer (Windows) @@ -198,8 +277,8 @@ if ([string]::IsNullOrWhiteSpace($Version)) { # --------------------------------------------------------------------------- # Skip if the requested version is already installed # --------------------------------------------------------------------------- -# Probe both names: the standalone install ships `altimate`, but an npm install -# also exposes the `altimate-code` alias. +# Probe both names: either can be the only one on PATH (a standalone install from +# before the `altimate-code` shims, or a package manager that exposes one name). $probe = $null foreach ($name in @("altimate", "altimate-code")) { $cmd = Get-Command $name -ErrorAction SilentlyContinue @@ -210,6 +289,12 @@ if ($probe) { try { $installedVersion = (& $probe --version 2>$null | Select-Object -First 1).ToString().Trim() } catch {} if ($installedVersion -eq $specificVersion) { Write-Muted "Version $specificVersion already installed" + # A standalone install from before the alias existed still gets it, but only when + # the matching binary is the one in $InstallDir, so the alias never runs a different + # version than the one just checked. + if (Test-PathInDir -Path $probe -Dir $InstallDir) { + Install-Alias -Dir $InstallDir + } exit 0 } elseif ($installedVersion) { Write-Muted "Installed version: $installedVersion." @@ -304,6 +389,8 @@ if (-not $needsBaseline) { } } +Install-Alias -Dir $InstallDir + # --------------------------------------------------------------------------- # Post-install marker (install telemetry) # --------------------------------------------------------------------------- diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 3d0d6e4ac1..017d290b42 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -20,8 +20,52 @@ interface RemovalTargets { directories: Array<{ path: string; label: string; keep: boolean }> shellConfig: string | null binary: string | null + // altimate_change start — installer files beside the binary that exist (see installerSiblingFiles) + installerFiles: string[] + // altimate_change end +} + +// altimate_change start — files the standalone installers place beside the running binary +/** + * The other names the standalone installers put in ~/.altimate/bin next to `binary`: the + * `altimate-code` alias (.cmd and extensionless shims on Windows, where older Git Bash installs + * left an .exe copy instead; a symlink elsewhere), the canonical binary, and the .exe.old copies + * left aside when an exe is locked, each unless it is the one running (`binary` can be a copied + * alias). Empty outside ~/.altimate/bin, so an unrelated `altimate-code` in a shared directory + * such as ~/.local/bin is never named for removal. + */ +export function installerSiblingFiles(binary: string, platform: NodeJS.Platform): string[] { + const paths = platform === "win32" ? path.win32 : path.posix + const binDir = paths.dirname(binary) + // Windows file names are case-insensitive; POSIX ones are not. + const fold = (name: string) => (platform === "win32" ? name.toLowerCase() : name) + if (fold(paths.basename(binDir)) !== "bin" || fold(paths.basename(paths.dirname(binDir))) !== ".altimate") return [] + const names = + platform === "win32" + ? [ + "altimate.exe", + "altimate.exe.old", + "altimate-code.cmd", + "altimate-code", + "altimate-code.exe", + "altimate-code.exe.old", + ] + : ["altimate", "altimate-code", "altimate-code.exe"] + const running = fold(paths.basename(binary)) + return names.filter((name) => name !== running).map((name) => paths.join(binDir, name)) } +/** + * A command that removes `file` in the shell the platform's installer runs in. The path is a + * single-quoted literal, so `$`, backticks or quotes in it are never expanded. + */ +export function removeFileCommand(file: string, platform: NodeJS.Platform, force = false): string { + // install.ps1 is a PowerShell one-liner; neither cmd.exe nor PowerShell runs `rm -f`. + if (platform === "win32") return `Remove-Item -LiteralPath '${file.replaceAll("'", "''")}' -Force` + return `rm${force ? " -f" : ""} '${file.replaceAll("'", "'\\''")}'` +} +// altimate_change end + export const UninstallCommand = { command: "uninstall", // altimate_change start — upstream_fix: branding regression in describe text @@ -81,7 +125,7 @@ export const UninstallCommand = { prompts.log.info(" bun: bun remove -g altimate-code") prompts.log.info(" yarn: yarn global remove altimate-code") prompts.log.info(" Homebrew: brew uninstall altimate-code") - prompts.log.info(` installer: delete the binary from ${standalone}`) + prompts.log.info(` installer: delete altimate and its altimate-code alias from ${standalone}`) prompts.log.info("If you installed the scoped package, use @altimateai/altimate-code as the name instead.") // Do not tell the user to "re-run" this command: once the package is gone, so is the // binary that would run it. Name the directories so data can be cleaned up by hand. @@ -153,8 +197,21 @@ async function collectRemovalTargets(args: UninstallArgs, method: Installation.M const shellConfig = method === "curl" ? await getShellConfigFile() : null const binary = method === "curl" ? process.execPath : null - - return { directories, shellConfig, binary } + // altimate_change start — a real directory at one of those names is not the installer's (it + // skips them), so it is left out + const candidates = binary ? installerSiblingFiles(binary, process.platform) : [] + const present = await Promise.all( + candidates.map((file) => + fs.lstat(file).then( + (stat) => !stat.isDirectory(), + () => false, + ), + ), + ) + const installerFiles = candidates.filter((_, index) => present[index]) + + return { directories, shellConfig, binary, installerFiles } + // altimate_change end } // altimate_change start — #1305: takes the verified package name so the summary prints the @@ -181,6 +238,11 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation. if (targets.binary) { prompts.log.info(` ✓ Binary: ${shortenPath(targets.binary)}`) } + // altimate_change start — the installer files are removed by hand too, so preview them + for (const file of targets.installerFiles) { + prompts.log.info(` ✓ Installer file: ${shortenPath(file)}`) + } + // altimate_change end if (targets.shellConfig) { prompts.log.info(` ✓ Shell PATH in ${shortenPath(targets.shellConfig)}`) @@ -281,7 +343,12 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar if (method === "curl" && targets.binary) { UI.empty() prompts.log.message("To finish removing the binary, run:") - prompts.log.info(` rm "${targets.binary}"`) + // altimate_change start — platform-native commands, plus the installer's other files + prompts.log.info(` ${removeFileCommand(targets.binary, process.platform)}`) + for (const file of targets.installerFiles) { + prompts.log.info(` ${removeFileCommand(file, process.platform, true)}`) + } + // altimate_change end const binDir = path.dirname(targets.binary) if (binDir.includes(".opencode")) { diff --git a/packages/opencode/test/cli/uninstall-hint.test.ts b/packages/opencode/test/cli/uninstall-hint.test.ts new file mode 100644 index 0000000000..6781b44c00 --- /dev/null +++ b/packages/opencode/test/cli/uninstall-hint.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" +import { installerSiblingFiles, removeFileCommand } from "../../src/cli/cmd/uninstall" + +describe("uninstall — installer files beside the binary", () => { + test("names the alias next to a standalone unix install", () => { + expect(installerSiblingFiles("/home/u/.altimate/bin/altimate", "linux")).toEqual([ + "/home/u/.altimate/bin/altimate-code", + // `install --binary altimate.exe` links this name on any OS + "/home/u/.altimate/bin/altimate-code.exe", + ]) + }) + + test("names every Windows alias form next to altimate.exe", () => { + expect(installerSiblingFiles("C:\\Users\\u\\.altimate\\bin\\altimate.exe", "win32")).toEqual([ + "C:\\Users\\u\\.altimate\\bin\\altimate.exe.old", + "C:\\Users\\u\\.altimate\\bin\\altimate-code.cmd", + "C:\\Users\\u\\.altimate\\bin\\altimate-code", + "C:\\Users\\u\\.altimate\\bin\\altimate-code.exe", + "C:\\Users\\u\\.altimate\\bin\\altimate-code.exe.old", + ]) + }) + + test("names the canonical binary when running from a copied alias", () => { + expect(installerSiblingFiles("C:\\Users\\u\\.altimate\\bin\\ALTIMATE-CODE.EXE", "win32")).toEqual([ + "C:\\Users\\u\\.altimate\\bin\\altimate.exe", + "C:\\Users\\u\\.altimate\\bin\\altimate.exe.old", + "C:\\Users\\u\\.altimate\\bin\\altimate-code.cmd", + "C:\\Users\\u\\.altimate\\bin\\altimate-code", + "C:\\Users\\u\\.altimate\\bin\\altimate-code.exe.old", + ]) + }) + + test("matches a differently cased install dir on Windows", () => { + expect(installerSiblingFiles("C:\\Users\\u\\.ALTIMATE\\BIN\\altimate.exe", "win32")).toContain( + "C:\\Users\\u\\.ALTIMATE\\BIN\\altimate-code.cmd", + ) + }) + + test("matches the running name case-sensitively on POSIX", () => { + expect(installerSiblingFiles("/home/u/.altimate/bin/ALTIMATE", "linux")).toEqual([ + "/home/u/.altimate/bin/altimate", + "/home/u/.altimate/bin/altimate-code", + "/home/u/.altimate/bin/altimate-code.exe", + ]) + }) + + test("names nothing outside the installers' own directory", () => { + expect(installerSiblingFiles("/home/u/.local/bin/altimate", "linux")).toEqual([]) + expect(installerSiblingFiles("/home/u/.opencode/bin/altimate", "linux")).toEqual([]) + expect(installerSiblingFiles("/home/u/.altimate/not-bin/altimate", "linux")).toEqual([]) + }) +}) + +describe("uninstall — removal commands", () => { + test("Windows gets a PowerShell command that runs where install.ps1 does", () => { + expect(removeFileCommand("C:\\Users\\u\\.altimate\\bin\\altimate-code.cmd", "win32", true)).toBe( + "Remove-Item -LiteralPath 'C:\\Users\\u\\.altimate\\bin\\altimate-code.cmd' -Force", + ) + expect(removeFileCommand("C:\\Users\\u\\.altimate\\bin\\altimate.exe", "win32")).toBe( + "Remove-Item -LiteralPath 'C:\\Users\\u\\.altimate\\bin\\altimate.exe' -Force", + ) + }) + + test("POSIX keeps rm, with -f only when asked", () => { + expect(removeFileCommand("/home/u/.altimate/bin/altimate", "linux")).toBe("rm '/home/u/.altimate/bin/altimate'") + expect(removeFileCommand("/home/u/.altimate/bin/altimate-code", "darwin", true)).toBe( + "rm -f '/home/u/.altimate/bin/altimate-code'", + ) + }) + + test("a path with $, backticks or quotes stays literal in either shell", () => { + expect(removeFileCommand("/home/o'neil $dev `x`/.altimate/bin/altimate", "linux")).toBe( + "rm '/home/o'\\''neil $dev `x`/.altimate/bin/altimate'", + ) + expect(removeFileCommand("C:\\Users\\o'neil $dev\\.altimate\\bin\\altimate.exe", "win32")).toBe( + "Remove-Item -LiteralPath 'C:\\Users\\o''neil $dev\\.altimate\\bin\\altimate.exe' -Force", + ) + }) +}) diff --git a/packages/opencode/test/install/install-alias.test.ts b/packages/opencode/test/install/install-alias.test.ts new file mode 100644 index 0000000000..3c091ef8ba --- /dev/null +++ b/packages/opencode/test/install/install-alias.test.ts @@ -0,0 +1,400 @@ +/** + * The standalone installers expose `altimate-code` beside `altimate`, the same + * pair of names npm, Homebrew and AUR ship. These run the real bash installer + * through its `--binary` path (no network) against a throwaway HOME. install.ps1 + * gets source-level checks here and execution in test/windows/install.Tests.ps1. + */ +import { describe, expect, test } from "bun:test" +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { spawnSync } from "node:child_process" +import { join } from "node:path" +import os from "os" + +const REPO_ROOT = join(import.meta.dir, "..", "..", "..", "..") +const INSTALL_SH_PATH = join(REPO_ROOT, "install") +const INSTALL_PS1 = readFileSync(join(REPO_ROOT, "install.ps1"), "utf-8") +const testUnlessWindows = process.platform === "win32" ? test.skip : test + +function withHome(fn: (home: string) => void) { + const home = mkdtempSync(join(os.tmpdir(), "install-alias-home-")) + try { + fn(home) + } finally { + rmSync(home, { recursive: true, force: true }) + } +} + +// The shims both Windows installers write; install.ps1's Install-Alias and the bash +// installer's write_windows_shims must produce exactly these bytes. +const CMD_SHIM = '@echo off\r\n"%~dp0altimate.exe" %*\r\nexit /b %ERRORLEVEL%\r\n' +const SH_SHIM_LINES = [ + "#!/bin/sh", + 'case "$(uname -s)" in', + ' MINGW*|MSYS*|CYGWIN*) exec "$(dirname "$0")/altimate.exe" "$@" ;;', + "esac", + 'echo "altimate-code: this is the Windows build; it runs only from Git Bash, MSYS or Cygwin" >&2', + "exit 127", +] +const SH_SHIM = SH_SHIM_LINES.join("\n") + "\n" + +function runInstaller(home: string, binaryName = "altimate", pathPrefix = "") { + const src = join(home, "src") + mkdirSync(src, { recursive: true }) + const fakeBin = join(src, binaryName) + writeFileSync(fakeBin, '#!/bin/sh\necho fake-altimate "$@"\n', { mode: 0o755 }) + const res = spawnSync("bash", [INSTALL_SH_PATH, "--binary", fakeBin, "--no-modify-path"], { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_DATA_HOME: join(home, "data"), + GITHUB_ACTIONS: "", + PATH: `${pathPrefix}${process.env.PATH}`, + }, + }) + return { code: res.status ?? -1, stderr: res.stderr ?? "", bin: join(home, ".altimate", "bin") } +} + +// A `uname` that reports Git Bash, so the installer takes its Windows path on any host. +function gitBashUname(dir: string) { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, "uname"), '#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || echo MINGW64_NT-10.0-19045\n', { + mode: 0o755, + }) + return dir +} + +// An offline release: a curl stub whose release-exists probe gets 200, whose download +// gets a local archive of `binary`, and for which checksums.txt is missing (the installer +// then skips verification). Returns the directory holding the stub. +function offlineRelease(home: string, binary: string) { + const stubs = join(home, "stubs") + const pkg = join(home, "pkg") + mkdirSync(stubs, { recursive: true }) + mkdirSync(pkg, { recursive: true }) + writeFileSync(join(pkg, "altimate"), binary, { mode: 0o755 }) + // Linux releases ship .tar.gz, every other target .zip (see `archive_ext` in install). + const archive = join(home, "release") + const pack = + process.platform === "linux" + ? spawnSync("tar", ["-czf", archive, "-C", pkg, "altimate"]) + : spawnSync("zip", ["-q", archive, "altimate"], { cwd: pkg }) + expect(pack.status).toBe(0) + writeFileSync( + join(stubs, "curl"), + [ + "#!/bin/sh", + 'out=""; prev=""', + 'for a in "$@"; do', + ' [ "$a" = "-w" ] && { echo 200; exit 0; }', + ' [ "$prev" = "-o" ] && out="$a"; prev="$a"', + "done", + `[ -n "$out" ] && exec cp "${archive}${process.platform === "linux" ? "" : ".zip"}" "$out"`, + "exit 22", + "", + ].join("\n"), + { mode: 0o755 }, + ) + return stubs +} + +describe("install — altimate-code alias, executed", () => { + testUnlessWindows("installs altimate-code as a relative link to altimate that runs it", () => { + withHome((home) => { + const { code, bin, stderr } = runInstaller(home) + expect(stderr).not.toMatch(/syntax error|command not found/) + expect(code).toBe(0) + const alias = join(bin, "altimate-code") + expect(lstatSync(alias).isSymbolicLink()).toBe(true) + expect(readlinkSync(alias)).toBe("altimate") + const run = spawnSync(alias, ["--version"], { encoding: "utf-8" }) + expect(run.stdout.trim()).toBe("fake-altimate --version") + }) + }) + + testUnlessWindows("replaces a stale altimate-code binary instead of leaving it on PATH", () => { + withHome((home) => { + const bin = join(home, ".altimate", "bin") + mkdirSync(bin, { recursive: true }) + writeFileSync(join(bin, "altimate-code"), "#!/bin/sh\necho stale\n", { mode: 0o755 }) + const { code } = runInstaller(home) + expect(code).toBe(0) + expect(lstatSync(join(bin, "altimate-code")).isSymbolicLink()).toBe(true) + expect(spawnSync(join(bin, "altimate-code"), [], { encoding: "utf-8" }).stdout.trim()).toBe("fake-altimate") + }) + }) + + testUnlessWindows("re-running the installer keeps a single working alias", () => { + withHome((home) => { + expect(runInstaller(home).code).toBe(0) + const { code, bin } = runInstaller(home) + expect(code).toBe(0) + expect(readlinkSync(join(bin, "altimate-code"))).toBe("altimate") + }) + }) + + testUnlessWindows("replaces a link to a directory without writing into that directory", () => { + withHome((home) => { + const bin = join(home, ".altimate", "bin") + const elsewhere = join(home, "elsewhere") + mkdirSync(bin, { recursive: true }) + mkdirSync(elsewhere) + symlinkSync(elsewhere, join(bin, "altimate-code")) + const { code } = runInstaller(home) + expect(code).toBe(0) + expect(readlinkSync(join(bin, "altimate-code"))).toBe("altimate") + expect(readdirSync(elsewhere)).toEqual([]) + }) + }) + + testUnlessWindows("the already-installed exit still adds the alias", () => { + withHome((home) => { + const bin = join(home, ".altimate", "bin") + const stubs = join(home, "stubs") + mkdirSync(bin, { recursive: true }) + mkdirSync(stubs) + writeFileSync(join(bin, "altimate"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 }) + // The release-exists probe is the only network call on a pinned version. + writeFileSync(join(stubs, "curl"), "#!/bin/sh\necho 200\n", { mode: 0o755 }) + const res = spawnSync("bash", [INSTALL_SH_PATH, "--version", "9.9.9", "--no-modify-path"], { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_DATA_HOME: join(home, "data"), + GITHUB_ACTIONS: "", + PATH: `${stubs}:${bin}:${process.env.PATH}`, + }, + }) + expect(res.stdout).toContain("already installed") + expect(res.status).toBe(0) + expect(readlinkSync(join(bin, "altimate-code"))).toBe("altimate") + }) + }) + + testUnlessWindows("the already-installed exit leaves the alias alone when the match is elsewhere on PATH", () => { + withHome((home) => { + const bin = join(home, ".altimate", "bin") + const other = join(home, "other") + const stubs = join(home, "stubs") + mkdirSync(bin, { recursive: true }) + mkdirSync(other) + mkdirSync(stubs) + // The matching version comes from another install earlier on PATH; the standalone + // binary is older, so an alias to it would run a different version. + writeFileSync(join(other, "altimate"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 }) + writeFileSync(join(bin, "altimate"), "#!/bin/sh\necho 1.0.0\n", { mode: 0o755 }) + writeFileSync(join(stubs, "curl"), "#!/bin/sh\necho 200\n", { mode: 0o755 }) + const res = spawnSync("bash", [INSTALL_SH_PATH, "--version", "9.9.9", "--no-modify-path"], { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_DATA_HOME: join(home, "data"), + GITHUB_ACTIONS: "", + PATH: `${stubs}:${other}:${bin}:${process.env.PATH}`, + }, + }) + expect(res.stdout).toContain("already installed") + expect(res.status).toBe(0) + expect(existsSync(join(bin, "altimate-code"))).toBe(false) + }) + }) + + testUnlessWindows("the already-installed exit matches the install dir through a symlinked PATH entry", () => { + withHome((home) => { + const bin = join(home, ".altimate", "bin") + const stubs = join(home, "stubs") + const linkedBin = join(home, "linked-bin") + mkdirSync(bin, { recursive: true }) + mkdirSync(stubs) + symlinkSync(bin, linkedBin) + writeFileSync(join(bin, "altimate"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 }) + writeFileSync(join(stubs, "curl"), "#!/bin/sh\necho 200\n", { mode: 0o755 }) + const res = spawnSync("bash", [INSTALL_SH_PATH, "--version", "9.9.9", "--no-modify-path"], { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_DATA_HOME: join(home, "data"), + GITHUB_ACTIONS: "", + PATH: `${stubs}:${linkedBin}/:${process.env.PATH}`, + }, + }) + expect(res.stdout).toContain("already installed") + expect(res.status).toBe(0) + expect(readlinkSync(join(bin, "altimate-code"))).toBe("altimate") + }) + }) + + testUnlessWindows("a downloaded release gets the alias", () => { + withHome((home) => { + const stubs = offlineRelease(home, '#!/bin/sh\necho downloaded "$@"\n') + const res = spawnSync("bash", [INSTALL_SH_PATH, "--version", "0.0.0-alias-test", "--no-modify-path"], { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_DATA_HOME: join(home, "data"), + GITHUB_ACTIONS: "", + PATH: `${stubs}:${process.env.PATH}`, + }, + }) + expect(res.stderr).not.toMatch(/syntax error|command not found/) + expect(res.status).toBe(0) + const alias = join(home, ".altimate", "bin", "altimate-code") + expect(readlinkSync(alias)).toBe("altimate") + expect(spawnSync(alias, ["--version"], { encoding: "utf-8" }).stdout.trim()).toBe("downloaded --version") + }) + }) + + testUnlessWindows("re-running --binary on the installed file itself restores the alias", () => { + withHome((home) => { + const { bin } = runInstaller(home) + rmSync(join(bin, "altimate-code")) + const res = spawnSync("bash", [INSTALL_SH_PATH, "--binary", join(bin, "altimate"), "--no-modify-path"], { + encoding: "utf-8", + env: { ...process.env, HOME: home, XDG_DATA_HOME: join(home, "data"), GITHUB_ACTIONS: "" }, + }) + expect(res.stdout).toContain("Source and destination are the same file") + expect(res.status).toBe(0) + expect(readlinkSync(join(bin, "altimate-code"))).toBe("altimate") + }) + }) + + testUnlessWindows("under Git Bash it writes the install.ps1 shims instead of copying the binary", () => { + withHome((home) => { + const uname = gitBashUname(join(home, "uname")) + const bin = join(home, ".altimate", "bin") + mkdirSync(bin, { recursive: true }) + // An older Git Bash install left a full copy here; PATHEXT would put it before the .cmd. + writeFileSync(join(bin, "altimate-code.exe"), "stale copy", { mode: 0o755 }) + const { code, stderr } = runInstaller(home, "altimate.exe", `${uname}:`) + expect(stderr).not.toMatch(/syntax error|command not found/) + expect(code).toBe(0) + expect(readFileSync(join(bin, "altimate-code.cmd"), "utf-8")).toBe(CMD_SHIM) + expect(readFileSync(join(bin, "altimate-code"), "utf-8")).toBe(SH_SHIM) + // Cygwin keeps POSIX modes, so the sh shim must carry the execute bit itself. + expect(lstatSync(join(bin, "altimate-code")).mode & 0o111).toBe(0o111) + expect(existsSync(join(bin, "altimate-code.exe"))).toBe(false) + }) + }) + + testUnlessWindows("under Git Bash a --binary named plain altimate still gets the alias", () => { + withHome((home) => { + const uname = gitBashUname(join(home, "uname")) + const { code, bin } = runInstaller(home, "altimate", `${uname}:`) + expect(code).toBe(0) + const alias = join(bin, "altimate-code") + expect(readlinkSync(alias)).toBe("altimate") + expect(spawnSync(alias, ["--version"], { encoding: "utf-8" }).stdout.trim()).toBe("fake-altimate --version") + expect(existsSync(join(bin, "altimate-code.cmd"))).toBe(false) + }) + }) + + testUnlessWindows("the sh shim forwards only under Git Bash and refuses elsewhere, e.g. WSL", () => { + withHome((home) => { + const uname = gitBashUname(join(home, "uname")) + const { bin } = runInstaller(home, "altimate.exe", `${uname}:`) + const shim = join(bin, "altimate-code") + const underGitBash = spawnSync("sh", [shim, "a b", "--version"], { + encoding: "utf-8", + env: { ...process.env, PATH: `${uname}:${process.env.PATH}` }, + }) + expect(underGitBash.stdout.trim()).toBe("fake-altimate a b --version") + const elsewhere = spawnSync("sh", [shim, "--version"], { encoding: "utf-8" }) + expect(elsewhere.status).toBe(127) + expect(elsewhere.stdout).toBe("") + }) + }) + + testUnlessWindows("in WSL the Windows install on PATH does not pass for an installed Linux build", () => { + withHome((home) => { + // The Windows install as WSL sees it: install.ps1's shims beside an altimate.exe + // that reports the same version the Linux installer is about to install. + const windowsHome = join(home, "windows") + const uname = gitBashUname(join(home, "uname")) + const { bin: windowsBin } = runInstaller(windowsHome, "altimate.exe", `${uname}:`) + writeFileSync(join(windowsBin, "altimate.exe"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 }) + const stubs = offlineRelease(home, '#!/bin/sh\necho linux "$@"\n') + const res = spawnSync("bash", [INSTALL_SH_PATH, "--version", "9.9.9", "--no-modify-path"], { + encoding: "utf-8", + env: { + ...process.env, + HOME: home, + XDG_DATA_HOME: join(home, "data"), + GITHUB_ACTIONS: "", + // System dirs only, so no `altimate` installed on the host answers the probe first. + PATH: `${stubs}:${windowsBin}:/usr/bin:/bin`, + }, + }) + expect(res.stdout).not.toContain("already installed") + expect(res.status).toBe(0) + const alias = join(home, ".altimate", "bin", "altimate-code") + expect(readlinkSync(alias)).toBe("altimate") + expect(spawnSync(alias, ["--version"], { encoding: "utf-8" }).stdout.trim()).toBe("linux --version") + }) + }) + + testUnlessWindows("a Windows binary gets an altimate-code.exe alias", () => { + withHome((home) => { + const { code, bin } = runInstaller(home, "altimate.exe") + expect(code).toBe(0) + expect(readlinkSync(join(bin, "altimate-code.exe"))).toBe("altimate.exe") + }) + }) + + testUnlessWindows("leaves a real directory at the alias path alone and still installs", () => { + withHome((home) => { + const bin = join(home, ".altimate", "bin") + mkdirSync(join(bin, "altimate-code"), { recursive: true }) + const { code, stderr } = runInstaller(home) + expect(stderr).not.toMatch(/syntax error|command not found/) + expect(code).toBe(0) + expect(lstatSync(join(bin, "altimate-code")).isDirectory()).toBe(true) + expect(readdirSync(join(bin, "altimate-code"))).toEqual([]) + expect(existsSync(join(bin, "altimate"))).toBe(true) + }) + }) + + testUnlessWindows("a --binary with an unrelated name gets no alias", () => { + withHome((home) => { + const { code, bin } = runInstaller(home, "altimate-dev") + expect(code).toBe(0) + expect(existsSync(join(bin, "altimate-dev"))).toBe(true) + expect(existsSync(join(bin, "altimate-code"))).toBe(false) + }) + }) +}) + +describe("install.ps1 — altimate-code alias", () => { + // Install-Alias itself is executed by test/windows/install.Tests.ps1; this pins its call sites. + test("installs the alias after the binary and on the already-installed exit", () => { + const calls = [...INSTALL_PS1.matchAll(/^\s*Install-Alias -Dir \$InstallDir\s*$/gm)].map((m) => m.index!) + expect(calls.length).toBe(2) + const alreadyInstalled = INSTALL_PS1.indexOf("already installed") + const exitAfter = INSTALL_PS1.slice(alreadyInstalled).search(/^\s*exit 0\b/m) + alreadyInstalled + expect(calls[0]).toBeGreaterThan(alreadyInstalled) + expect(calls[0]).toBeLessThan(exitAfter) + expect(calls[1]).toBeGreaterThan(INSTALL_PS1.indexOf("Install-Target -Baseline:$true")) + // The already-installed alias is only for a match found in $InstallDir itself. + const guard = INSTALL_PS1.indexOf("if (Test-PathInDir -Path $probe -Dir $InstallDir) {") + expect(guard).toBeGreaterThan(alreadyInstalled) + expect(guard).toBeLessThan(calls[0]) + }) + + test("writes the same sh shim lines as the bash installer", () => { + for (const line of SH_SHIM_LINES) expect(INSTALL_PS1).toContain(`'${line}'`) + }) +}) diff --git a/packages/opencode/test/skill/release-v0.7.1-binary-adversarial.test.ts b/packages/opencode/test/skill/release-v0.7.1-binary-adversarial.test.ts index f72e9e169e..15862e815e 100644 --- a/packages/opencode/test/skill/release-v0.7.1-binary-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.7.1-binary-adversarial.test.ts @@ -92,13 +92,12 @@ describe("v0.7.1 PR #820 — install script: review-driven hardening", () => { expect(installScript).toContain(`"$binary_path" -ef "$dest_path"`) expect(installScript).toContain("Source and destination are the same file") }) - test("stale altimate-code binary cleanup runs after fresh install", () => { - // Pre-rename users have ~/.altimate-code/bin/altimate-code on PATH; the - // rename only moves to ~/.altimate/bin, so the old binary lingers as a - // stale v0.7.0 binary on $PATH. Cleanup must remove it after install. - expect(installScript).toContain(`rm -f "\${INSTALL_DIR}/altimate-code"`) - expect(installScript).toContain("Removed stale") - expect(installScript).toContain("renamed to altimate in v0.7.1") + test("a stale altimate-code binary is replaced by the alias after fresh install", () => { + // A pre-rename binary left in INSTALL_DIR must never run by name; the + // forced symlink overwrites it with an alias to the fresh `altimate`. + // Behavioural coverage lives in test/install/install-alias.test.ts. + expect(installScript).toContain(`ln -sfn "$target" "$alias_name"`) + expect(installScript).toContain('link_alias "$binary_name"') }) test("tar/unzip extract only the expected binary member", () => { // Defense in depth: if a future build mistakenly tars a directory of diff --git a/packages/opencode/test/skill/release-v0.7.2-adversarial.test.ts b/packages/opencode/test/skill/release-v0.7.2-adversarial.test.ts index 635f5b1b3e..965fd5cacf 100644 --- a/packages/opencode/test/skill/release-v0.7.2-adversarial.test.ts +++ b/packages/opencode/test/skill/release-v0.7.2-adversarial.test.ts @@ -205,8 +205,8 @@ describe("github/action.yml — post-v0.7.1 rename alignment", () => { }) test("Action invokes the renamed binary, not the legacy name", () => { - // v0.7.1 shipped only `altimate` from the curl path. `altimate-code` does - // not exist on PATH after a curl install; the npm path still has both. + // `altimate` is the canonical name on every install path; older curl + // installs (v0.7.1 until the alias was added) have no `altimate-code`. expect(ACTION_YML).toContain("run: altimate github run") expect(ACTION_YML).not.toMatch(/run:\s*altimate-code github run/) }) diff --git a/test/windows/install.Tests.ps1 b/test/windows/install.Tests.ps1 index 31e89ed287..ba28bf8993 100644 --- a/test/windows/install.Tests.ps1 +++ b/test/windows/install.Tests.ps1 @@ -320,3 +320,208 @@ Describe "install.ps1 Write-InstallMarker" { $ErrorActionPreference | Should -Be "Stop" } } + +Describe "install.ps1 Install-Alias" { + BeforeAll { + $src = Get-Content -Raw $script:InstallScript + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($src, [ref]$tokens, [ref]$errors) + $def = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Install-Alias" + }, $true) + if (-not $def) { throw "Install-Alias not found in install.ps1" } + . ([ScriptBlock]::Create($def.Extent.Text)) + function Write-Muted($msg) { } + + # Same reason as Write-InstallMarker: the installer runs under "Stop". + $ErrorActionPreference = "Stop" + $script:ErrorActionPreference = "Stop" + } + + BeforeEach { + $script:Sandbox = Join-Path ([IO.Path]::GetTempPath()) ("alias-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $script:Sandbox | Out-Null + } + + AfterEach { + Remove-Item -Recurse -Force $script:Sandbox -ErrorAction SilentlyContinue + } + + It "writes an altimate-code.cmd shim that forwards to altimate.exe" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + Install-Alias -Dir $script:Sandbox + + $shim = [IO.File]::ReadAllText([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) + $shim | Should -Be "@echo off`r`n`"%~dp0altimate.exe`" %*`r`nexit /b %ERRORLEVEL%`r`n" + } + + It "writes an extensionless shim for Git Bash that forwards to altimate.exe only there" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + Install-Alias -Dir $script:Sandbox + + $shim = [IO.File]::ReadAllText([IO.Path]::Combine($script:Sandbox, "altimate-code")) + $expected = ((@( + '#!/bin/sh' + 'case "$(uname -s)" in' + ' MINGW*|MSYS*|CYGWIN*) exec "$(dirname "$0")/altimate.exe" "$@" ;;' + 'esac' + 'echo "altimate-code: this is the Windows build; it runs only from Git Bash, MSYS or Cygwin" >&2' + 'exit 127' + ) -join "`n") + "`n") + $shim | Should -Be $expected + } + + # Needs cmd.exe; skipped when the suite runs under pwsh on macOS or Linux. + It "runs altimate.exe through the .cmd shim with its arguments and exit code" -Skip:($env:OS -ne "Windows_NT") { + # cmd.exe stands in for altimate.exe: a real executable whose output and exit + # code are driven entirely by the forwarded arguments. + Copy-Item -LiteralPath $env:ComSpec -Destination ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) + Install-Alias -Dir $script:Sandbox + $shim = [IO.Path]::Combine($script:Sandbox, "altimate-code.cmd") + + (& $shim /c echo forwarded) | Should -Be "forwarded" + & $shim /c exit 7 + $LASTEXITCODE | Should -Be 7 + } + + It "replaces a hard link at the shim path without writing through it" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + $outside = [IO.Path]::Combine($script:Sandbox, "outside.txt") + Set-Content -Path $outside -Value "untouched" -NoNewline + New-Item -ItemType HardLink -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) -Target $outside | Out-Null + Install-Alias -Dir $script:Sandbox + + [IO.File]::ReadAllText($outside) | Should -Be "untouched" + [IO.File]::ReadAllText([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) | Should -Match "altimate.exe" + } + + It "replaces a junction or directory link at the shim path without touching its target" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + $outsideDir = [IO.Path]::Combine($script:Sandbox, "outside") + New-Item -ItemType Directory -Path $outsideDir | Out-Null + Set-Content -Path ([IO.Path]::Combine($outsideDir, "keep.txt")) -Value "untouched" -NoNewline + # pwsh off Windows silently creates no junction; a directory symlink is the same case there. + $linkType = if ($env:OS -eq "Windows_NT") { "Junction" } else { "SymbolicLink" } + $link = [IO.Path]::Combine($script:Sandbox, "altimate-code.cmd") + New-Item -ItemType $linkType -Path $link -Target $outsideDir | Out-Null + (Get-Item -LiteralPath $link -Force).LinkType | Should -Be $linkType + Install-Alias -Dir $script:Sandbox + + [IO.File]::ReadAllText([IO.Path]::Combine($outsideDir, "keep.txt")) | Should -Be "untouched" + Test-Path -LiteralPath ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) -PathType Leaf | Should -BeTrue + } + + It "cleans up an altimate-code.exe.old left by an earlier run" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe.old")) -Value "old" -NoNewline + Install-Alias -Dir $script:Sandbox + + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe.old")) | Should -BeFalse + } + + It "removes a stale altimate-code.exe that would shadow the shim" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe")) -Value "stale" -NoNewline + Install-Alias -Dir $script:Sandbox + + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe")) | Should -BeFalse + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) | Should -BeTrue + } + + It "creates nothing when altimate.exe is not installed" { + Install-Alias -Dir $script:Sandbox + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) | Should -BeFalse + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code")) | Should -BeFalse + } + + It "keeps an existing altimate-code.exe when the shim cannot be written" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe")) -Value "old" -NoNewline + New-Item -ItemType Directory -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) | Out-Null + + { Install-Alias -Dir $script:Sandbox } | Should -Not -Throw + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe")) | Should -BeTrue + } + + It "keeps the Git Bash shim when the .cmd shim cannot be written" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code")) -Value "previous" -NoNewline + New-Item -ItemType Directory -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) | Out-Null + + { Install-Alias -Dir $script:Sandbox } | Should -Not -Throw + [IO.File]::ReadAllText([IO.Path]::Combine($script:Sandbox, "altimate-code")) | Should -Be "previous" + } + + It "removes a shadowing altimate-code.exe once the .cmd exists, even if the Git Bash shim fails" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe")) -Value "stale" -NoNewline + New-Item -ItemType Directory -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code")) | Out-Null + + { Install-Alias -Dir $script:Sandbox } | Should -Not -Throw + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) -PathType Leaf | Should -BeTrue + Test-Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.exe")) | Should -BeFalse + } + + # Needs symlink privilege, which Windows CI runners have; off Windows the test above covers it. + It "replaces a Windows directory symlink at the shim path without touching its target" -Skip:($env:OS -ne "Windows_NT") { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + $outsideDir = [IO.Path]::Combine($script:Sandbox, "outside") + New-Item -ItemType Directory -Path $outsideDir | Out-Null + Set-Content -Path ([IO.Path]::Combine($outsideDir, "keep.txt")) -Value "untouched" -NoNewline + $link = [IO.Path]::Combine($script:Sandbox, "altimate-code.cmd") + New-Item -ItemType SymbolicLink -Path $link -Target $outsideDir | Out-Null + Install-Alias -Dir $script:Sandbox + + [IO.File]::ReadAllText([IO.Path]::Combine($outsideDir, "keep.txt")) | Should -Be "untouched" + Test-Path -LiteralPath $link -PathType Leaf | Should -BeTrue + } + + It "does not throw when the shim cannot be written" { + Set-Content -Path ([IO.Path]::Combine($script:Sandbox, "altimate.exe")) -Value "x" -NoNewline + New-Item -ItemType Directory -Path ([IO.Path]::Combine($script:Sandbox, "altimate-code.cmd")) | Out-Null + + { Install-Alias -Dir $script:Sandbox } | Should -Not -Throw + } +} + +Describe "install.ps1 Test-PathInDir" { + BeforeAll { + $src = Get-Content -Raw $script:InstallScript + $tokens = $null; $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput($src, [ref]$tokens, [ref]$errors) + $def = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Test-PathInDir" + }, $true) + if (-not $def) { throw "Test-PathInDir not found in install.ps1" } + . ([ScriptBlock]::Create($def.Extent.Text)) + $script:Dir = [IO.Path]::Combine([IO.Path]::GetTempPath(), "altimate-home", ".altimate", "bin") + } + + It "matches a file directly in the directory" { + Test-PathInDir -Path ([IO.Path]::Combine($script:Dir, "altimate.exe")) -Dir $script:Dir | Should -BeTrue + } + + It "ignores a trailing separator on the directory" { + Test-PathInDir -Path ([IO.Path]::Combine($script:Dir, "altimate.exe")) -Dir ($script:Dir + [IO.Path]::DirectorySeparatorChar) | Should -BeTrue + } + + It "ignores case" { + Test-PathInDir -Path ([IO.Path]::Combine($script:Dir.ToUpperInvariant(), "altimate.exe")) -Dir $script:Dir | Should -BeTrue + } + + It "resolves . and .. segments" { + $spelled = [IO.Path]::Combine($script:Dir, "..", "bin", ".", "altimate.exe") + Test-PathInDir -Path $spelled -Dir $script:Dir | Should -BeTrue + } + + It "does not match another directory" { + $other = [IO.Path]::Combine([IO.Path]::GetTempPath(), "npm", "altimate.exe") + Test-PathInDir -Path $other -Dir $script:Dir | Should -BeFalse + } + + It "does not match a subdirectory" { + Test-PathInDir -Path ([IO.Path]::Combine($script:Dir, "nested", "altimate.exe")) -Dir $script:Dir | Should -BeFalse + } +}