Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 87 additions & 20 deletions packages/opencode/src/cli/cmd/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,60 @@ export const UninstallCommand = {
const method = await Installation.method()
prompts.log.info(`Installation method: ${method}`)

// altimate_change start — #1305: refuse BEFORE removing anything when we cannot tell what
// installed this binary.
//
// `unknown` means detection could not confirm an owner. The removal targets below always
// include data, config, cache and state, while the binary and the package-manager entry
// are only removed for a known method — so proceeding here wiped everything the user
// cares about and left the installation running, with no indication that had happened.
// Data loss with nothing uninstalled is strictly worse than declining.
if (method === "unknown") {
const win = process.platform === "win32"
const standalone = win ? "%USERPROFILE%\\.altimate\\bin" : "~/.altimate/bin"
prompts.log.error(`Cannot determine how altimate was installed (running from ${process.execPath}).`)
prompts.log.info("Uninstalling now would delete your data and config while leaving the program installed.")
prompts.log.info("Remove the program with whichever tool installed it — each has its own syntax:")
prompts.log.info(" npm: npm uninstall -g altimate-code")
prompts.log.info(" pnpm: pnpm uninstall -g altimate-code")
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("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.
prompts.log.info("Then delete these directories to remove data, config, cache and state:")
for (const dir of [Global.Path.data, Global.Path.config, Global.Path.cache, Global.Path.state]) {
prompts.log.info(` ${dir}`)
}
prompts.outro("Nothing was removed")
return
}
// altimate_change end

// altimate_change start — #1305: the package the MANAGER confirms owns this binary.
// publish.ts ships both a scoped and an unscoped wrapper; removing the wrong one removes
// nothing while uninstall goes on to delete config and cache.
//
// No `?? "@altimateai/altimate-code"` default here. A package-manager method is only
// returned once ownership was confirmed, so a missing name alongside one of those methods
// is a contradiction, not a case to guess through — and guessing is precisely what made an
// earlier revision delete a user's data and then remove a package that was not installed.
const pkg = await Installation.packageName()
const managed = method === "npm" || method === "pnpm" || method === "bun" || method === "yarn"
if (managed && !pkg) {
prompts.log.error(`Detected a ${method} installation but could not confirm which package owns it.`)
prompts.log.info("Nothing was removed. Remove the package with your package manager, then delete:")
for (const dir of [Global.Path.data, Global.Path.config, Global.Path.cache, Global.Path.state]) {
prompts.log.info(` ${dir}`)
}
prompts.outro("Nothing was removed")
return
}
const targets = await collectRemovalTargets(args, method)

await showRemovalSummary(targets, method)
await showRemovalSummary(targets, method, pkg ?? "altimate-code")
// altimate_change end

if (!args.force && !args.dryRun) {
const confirm = await prompts.confirm({
Expand All @@ -83,7 +134,10 @@ export const UninstallCommand = {
return
}

await executeUninstall(method, targets)
// altimate_change start — #1305: pass the verified package name through so removal
// targets the wrapper the user actually installed.
await executeUninstall(method, targets, pkg ?? "altimate-code")
// altimate_change end

prompts.outro("Done")
},
Expand All @@ -103,7 +157,10 @@ async function collectRemovalTargets(args: UninstallArgs, method: Installation.M
return { directories, shellConfig, binary }
}

async function showRemovalSummary(targets: RemovalTargets, method: Installation.Method) {
// altimate_change start — #1305: takes the verified package name so the summary prints the
// command that will actually run.
async function showRemovalSummary(targets: RemovalTargets, method: Installation.Method, pkg: string) {
// altimate_change end
prompts.log.message("The following will be removed:")

for (const dir of targets.directories) {
Expand All @@ -130,20 +187,26 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation.
}

if (method !== "curl" && method !== "unknown") {
// altimate_change start — #1305: these targeted upstream's `opencode-ai` / `opencode`,
// so an uninstall could remove an unrelated upstream package while leaving Altimate
// installed. scoop/choco are omitted: Installation.method() no longer returns them
// (their commands still reference upstream identities), so they are unreachable here.
const cmds: Record<string, string> = {
npm: "npm uninstall -g opencode-ai",
pnpm: "pnpm uninstall -g opencode-ai",
bun: "bun remove -g opencode-ai",
yarn: "yarn global remove opencode-ai",
brew: "brew uninstall opencode",
choco: "choco uninstall opencode",
scoop: "scoop uninstall opencode",
npm: `npm uninstall -g ${pkg}`,
pnpm: `pnpm uninstall -g ${pkg}`,
bun: `bun remove -g ${pkg}`,
yarn: `yarn global remove ${pkg}`,
brew: "brew uninstall altimate-code",
}
// altimate_change end
prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
}
}

async function executeUninstall(method: Installation.Method, targets: RemovalTargets) {
// altimate_change start — #1305: takes the verified package name so removal targets the
// wrapper the user actually installed.
async function executeUninstall(method: Installation.Method, targets: RemovalTargets, pkg: string) {
// altimate_change end
const spinner = prompts.spinner()
const errors: string[] = []

Expand Down Expand Up @@ -181,22 +244,26 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
}

if (method !== "curl" && method !== "unknown") {
// altimate_change start — #1305: Altimate package identities, not upstream's.
const cmds: Record<string, string[]> = {
npm: ["npm", "uninstall", "-g", "opencode-ai"],
pnpm: ["pnpm", "uninstall", "-g", "opencode-ai"],
bun: ["bun", "remove", "-g", "opencode-ai"],
yarn: ["yarn", "global", "remove", "opencode-ai"],
brew: ["brew", "uninstall", "opencode"],
choco: ["choco", "uninstall", "opencode"],
scoop: ["scoop", "uninstall", "opencode"],
npm: ["npm", "uninstall", "-g", pkg],
pnpm: ["pnpm", "uninstall", "-g", pkg],
bun: ["bun", "remove", "-g", pkg],
yarn: ["yarn", "global", "remove", pkg],
brew: ["brew", "uninstall", "altimate-code"],
}
// altimate_change end

const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
// altimate_change start — #1305: the choco special-case here passed a hardcoded
// `["choco","uninstall","opencode",...]`; choco is no longer a reachable method (see
// the command map above), so the branch is gone and `cmd` is used directly.
const result = await Process.run(cmd, {
nothrow: true,
})
// altimate_change end
if (result.code !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
Expand Down
55 changes: 38 additions & 17 deletions packages/opencode/src/cli/cmd/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ export const UpgradeCommand = {
alias: "m",
describe: "installation method to use",
type: "string",
choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"],
// altimate_change start — #1305: keep in step with UNSUPPORTED_UPGRADE_METHODS.
// choco/scoop were offered here but Installation.upgrade() always refuses them, so
// selecting either could only fail.
choices: ["curl", "npm", "pnpm", "bun", "brew"],
// altimate_change end
})
},
handler: async (args: { target?: string; method?: string }) => {
Expand All @@ -46,23 +50,40 @@ export const UpgradeCommand = {
// altimate_change end
const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") {
// altimate_change start — branding
prompts.log.error(`altimate is installed to ${process.execPath} and may be managed by a package manager`)
// altimate_change end
const install = await prompts.select({
message: "Install anyways?",
options: [
{ label: "Yes", value: true },
{ label: "No", value: false },
],
initialValue: false,
})
if (!install) {
prompts.outro("Done")
return
}
// altimate_change start — #1305: stop instead of offering a choice that cannot work.
// `Installation.upgrade()` refuses every method in UNSUPPORTED_UPGRADE_METHODS, so the
// old "Install anyways?" prompt ended in `UpgradeFailedError: Unknown installation
// method` whichever way the user answered — and detection now returns `unknown` for
// anything it cannot verify, which made that dead end much more common.
if (Installation.UNSUPPORTED_UPGRADE_METHODS.includes(method)) {
prompts.log.error(
method === "unknown"
? `Cannot determine how altimate was installed (running from ${process.execPath}).`
: `Upgrading a ${method} installation is not supported.`,
)
// altimate_change — #1305: echo the target the user actually asked for. Printing
// `@latest` after `altimate upgrade 0.8.10` tells them to install something other than
// what they requested.
const want = args.target ? args.target.replace(/^v/, "") : "latest"
prompts.log.info("Upgrade with whichever tool installed it:")
prompts.log.info(` npm: npm install -g altimate-code@${want}`)
prompts.log.info(` pnpm: pnpm install -g altimate-code@${want}`)
prompts.log.info(` bun: bun install -g altimate-code@${want}`)
// altimate_change — #1305: yarn is in UNSUPPORTED_UPGRADE_METHODS and routes here, so it
// needs a line; uninstall.ts's equivalent message already had one.
prompts.log.info(` yarn: yarn global add altimate-code@${want}`)
prompts.log.info(" Homebrew: brew upgrade altimate-code")
prompts.log.info(
process.platform === "win32"
? " installer: irm https://www.altimate.sh/install.ps1 | iex"
: " installer: curl -fsSL https://www.altimate.sh/install | bash",
)
prompts.log.info("If you installed the scoped package, use @altimateai/altimate-code as the name instead.")
prompts.log.info("Or force a specific manager with --method <npm|pnpm|bun|brew|curl>.")
prompts.outro("Done")
return
}
// altimate_change end
prompts.log.info("Using method: " + method)
const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL — altimate upgrade latest (or any non-exact-version target) always misreports a successful upgrade as failed

const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()

When the user supplies an explicit target argument, it is used verbatimInstallation.latest() is only called when args.target is absent. So altimate upgrade latest sets target = "latest" and that literal string flows into Installation.upgrade(method, target), which passes it straight to the package manager (npm install -g altimate-code@latest, etc.). npm/pnpm/bun correctly resolve "latest" to a concrete version and install it successfully — but the post-upgrade verification (index.ts:1069) then compares the actual resolved version against the literal string "latest":

const contradicted = after !== "" && normalize(after) !== normalize(target)

normalize("0.12.0") !== normalize("latest") is always true — there is no code path that ever makes these equal. The result: every altimate upgrade latest succeeds at the package-manager level and is then unconditionally reported as a failure, with a misleading "the package manager reported success but wrote somewhere other than the running executable" message. The same applies to any dist-tag (beta, next) or semver range (^0.12.0) passed as an explicit target.

This is not probabilistic or environment-dependent like the Homebrew Cellar-cleanup finding from last round — it is deterministic and 100% reproducible. It also breaks the "already up to date, skip" fast path a few lines above (Installation.VERSION === target), which likewise can never match a tag/range string. altimate upgrade latest mirrors a convention users already know from npm install -g pkg@latest, so this is a realistic, not contrived, way to trigger it.

Suggestion: Resolve target to a concrete version before it's used for anything comparison-sensitive:

const target = args.target
  ? args.target.replace(/^v/, "")
  : await Installation.latest()
const resolvedTarget =
  /^\d+\.\d+\.\d+/.test(target) ? target : await Installation.latest()

(Or, more robustly, have the post-upgrade verification treat a non-semver target as "cannot verify against this value" rather than as a hard mismatch — falling back to the existing "ran but reported no comparable version" branch instead of the contradicted branch.)

(Independently found by MiniMax M2.7 with this precise line-level trace; Qwen 3.6 flagged the general shape — comparing raw target against resolved version — in less precise form. Claude confirmed the exact mechanism by re-reading this ternary's control flow.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cc57ee1. This one was unambiguous and I should have caught it when I wrote the check — target is whatever the caller passed, and I never asked what values it can take before comparing a resolved version against it.

Verification now only contradicts when the target is an exact version:

const exactTarget = semver.valid(normalize(target)) !== null
const contradicted = exactTarget && after !== "" && normalize(after) !== normalize(target)

A dist-tag or range is logged as "not verified against it" rather than failed — comparing a resolved version to an unresolved specifier is not a check, so the honest outcome is "unverifiable", the same category already used for a binary that runs but prints nothing.

I did not resolve the specifier up front instead (calling latest() when the target is not exact). It would restore a real check, but each manager resolves tags and ranges by its own rules, so we would be re-implementing their resolution to grade their work — and getting that subtly wrong reintroduces exactly this class of false failure.

Tests cover latest, beta and ^0.12.0 not failing, plus an exact target that genuinely does not match afterwards still failing, so the check has not simply been weakened into a no-op.


Expand Down
Loading
Loading