fix: resolve the install from the running binary; make upgrade failures diagnosable (#1305)
Fixes #1305.
The bug
Installation.method() never established where the running executable came from — it guessed, two ways, and both were unsound.
1. Substring test on process.execPath. ~/.local/bin is a generic user bin directory, not a marker of a standalone install. With npm config set prefix ~/.local — a common way to avoid needing sudo — an npm install was classified curl, so altimate upgrade ran curl … | bash, wrote a standalone binary, and left the npm-managed copy stale and orphaned. Two installs then coexisted and PATH order decided which ran.
2. A probe loop that asked the wrong question. npm list -g, brew list, etc., returning the first manager whose output mentioned the package. That answers "is this installed anywhere?", not "did this running binary come from you" — so with more than one install present the result was effectively arbitrary, and upgrades targeted an install the user was not running.
On top of that, the in-app Update now button could never succeed on a root-owned npm prefix: upgrade() shelled out as the current user with no writability check, npm failed with EACCES, and the error surfaced as a generic Upgrade failed for npm (exit code 243).
The fix
resolveInstall() — resolve, don't guess. Resolves realpath(process.execPath) and matches the package segment. The npm bin/altimate shim is a Node script that spawnSync()s the per-platform package, so inside the CLI execPath is:
<prefix>/lib/node_modules/@altimateai/altimate-code/node_modules/
@altimateai/altimate-code-darwin-arm64/bin/altimate-code
i.e. it always lands under node_modules for every package-manager install. The optional -<platform>-<arch> suffix is matched explicitly rather than relying on the wrapper name happening to be a prefix of the platform package name. Homebrew is matched on the Cellar segment (not the prefix — /usr/local collides with a common npm prefix), and .local/bin is gone.
This removes up to seven subprocess spawns from the startup update-check path; the new resolver spawns nothing.
Writability preflight. An upgrade that cannot succeed is now refused before shelling out, naming the directory and the exact remedy:
Cannot write to the npm global prefix (/usr/local). Run `sudo npm install -g
@altimateai/altimate-code@0.11.2`, or switch to a user-owned prefix with
`npm config set prefix ~/.npm-global`.
Uses npm root -g rather than <prefix>/lib/node_modules (Unix-only — Windows puts packages at <prefix>/node_modules and shims at <prefix>), and derives the bin dir from npm prefix -g because npm bin -g was removed in npm 9. pnpm/yarn check both the root and the bin dir, since a global install writes both. brew/scoop/choco are skipped — their tooling owns elevation.
A directory that does not exist yet is not a permission problem, so only an existing unwritable directory blocks.
Non-permission failures are now diagnosable. The failure branch had an asymmetry:
if (!upgradeResult || upgradeResult.code !== 0) {
const stderr = upgradeFailure(m, upgradeResult) // the generic string, NOT the real stderr
...
}
yield* Effect.logInfo("upgraded", { stdout: upgradeResult.stdout, stderr: upgradeResult.stderr })
The real diagnostic output was logged on success and discarded on failure. So network loss, E404, ENOSPC or a failing lifecycle script all collapsed into the same opaque message with nothing written anywhere, and telemetry got the generic string too — every failed upgrade looked identical on a dashboard.
Now: the real stdout/stderr is logged locally (the log file never leaves the machine, and the success path already wrote the same content), the user-facing message adds a classified hint plus a pointer to the log, and telemetry records a stable code (permission, network, not-found, disk-full, no-matching-version, unknown) with the exit status. The user-facing message and the telemetry payload stay redacted — stderr is never echoed into either.
Not included, deliberately
No auto-sudo. A TUI cannot host an interactive password prompt safely, sudo npm install -g runs package lifecycle scripts as root, and it would let a network-sourced version check trigger root-level writes. The message tells the user what to run instead.
Tests
New test/installation/resolve-install.test.ts — 16 table-driven cases over fabricated layouts (npm default prefix, npm under ~/.local, pnpm virtual store and plain global link, bun, yarn, brew on both Apple Silicon and Intel prefixes, standalone current and pre-v0.7.1, scoop, choco, dev build, pinned ALTIMATE_CODE_BIN_PATH). resolveInstall() is pure in (execPath, env) precisely so these layouts can be tested without real installs.
Four existing tests asserted on source text or exact error strings and were updated to track the new contract while preserving their intent:
| Test | Was | Now |
|---|---|---|
test/install/upgrade-method.test.ts | toContain("exec.includes(a.name)") | asserts the resolver contract; asserts the probe loop stays gone; new .local/bin regression test |
test/branding/upstream-merge-guard.test.ts | sliced the method: block for @altimateai/altimate-code | slices the detection segment instead; still guards scope vs opencode-ai |
test/installation/installation.test.ts (×2) | exact-equality on the sanitized message | prefix match + log pointer; redaction assertions unchanged |
test/release-validation/windows-installer-930.test.ts | exact message + generic telemetry string | prefix match; telemetry now "unknown: exit 1"; redaction assertions unchanged |
The brand guard and the redaction guards were updated, never weakened — every not.toContain("secret") assertion still stands.
568 pass, 5 skip, 0 fail (installation, install, branding, release-validation)
typecheck: clean lint: 0 errors
Follow-ups (not in this PR)
uninstallroutes onmethod()(cmd/uninstall.ts:62), so detection changes what gets deleted. Accuracy improves it, but it should enumerate other discoverablealtimatebinaries rather than silently removing one — otherwise a corrected detection can leave the orphan that causes the shadowing bug in the first place.vscode-extensionis unmodeled.welcome.ts:15calls it "the dominant installer by volume", yetInstallation.Methodhas no such variant; those installs resolve tounknown(notify-only), which is safe but not right.- Two disagreeing notions of install method —
Installation.method()(upgrades) andwelcome.ts readInstallMethod()(telemetry, marker-based and single-use). This PR fixes the first only.
🤖 Generated with Claude Code
Summary by cubic
Fixes #1305. Installation detection no longer guesses from process.execPath substrings or package-manager listings; it resolves the running binary and confirms package-manager ownership before upgrading or uninstalling, so actions target the install the user is actually running. Unmanaged installs fail safely with actionable guidance, and upgrade failures now include classified, redacted diagnostics.
Behavior
- Replaces up to seven startup package-manager probes with path resolution and a single race-free cached ownership lookup; confirmed answers are cached, failed probes get exactly one shared retry.
- Confirms ownership only when the binary sits in the manager's own top-level tree or virtual store, so another package's dependency is never claimed as ours.
- Uses the verified scoped or unscoped package name for upgrades and uninstalls; an unconfirmed package refuses instead of assuming the scoped name.
- Refuses to uninstall when ownership is unknown, before deleting data, config, cache, or state.
- Replaces unsupported Yarn, Scoop, and Chocolatey upgrades and the dead-end "Install anyways?" prompt with consistent recovery guidance across CLI and HTTP routes.
- Checks upgrade targets for write access before running package-manager commands and provides a specific remedy without auto-escalating.
- Logs redacted command output, adds classified failure codes and log-file guidance.
- Post-upgrade verification only fails on exact version targets; dist-tags, ranges, and relocated binaries (for example Homebrew) are reported as unverifiable, not failed.
Tests
- Adds coverage for package resolution, global ownership, symlink-safe containment, cache and local installs, redaction, exact and non-exact verification, and concurrent identity lookups.
Summary by CodeRabbit
- New Features
- Improved installation detection across package managers, standalone downloads, development builds, and temporary or cached paths.
- Added safeguards to prevent upgrades when the running binary is not owned by the detected installation manager.
- Bug Fixes
- Upgrade failures now redact sensitive information and point to detailed local logs when available.
- Improved handling of unsupported Yarn, Scoop, and Chocolatey installations.
- Uninstall now targets the correct Altimate package names.
- Failed-upgrade telemetry uses stable, sanitized classifications.
- Tests
- Expanded coverage for installation detection, ownership, upgrade failures, branding, and Windows installer errors.