feat: add uninstall command (#5208)

This commit is contained in:
rari404 2025-12-07 14:23:30 -05:00 committed by GitHub
parent 9c938eec73
commit ec27759f90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 346 additions and 0 deletions

View file

@ -0,0 +1,344 @@
import type { Argv } from "yargs"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { Global } from "../../global"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
interface UninstallArgs {
keepConfig: boolean
keepData: boolean
dryRun: boolean
force: boolean
}
interface RemovalTargets {
directories: Array<{ path: string; label: string; keep: boolean }>
shellConfig: string | null
binary: string | null
}
export const UninstallCommand = {
command: "uninstall",
describe: "uninstall opencode and remove all related files",
builder: (yargs: Argv) =>
yargs
.option("keep-config", {
alias: "c",
type: "boolean",
describe: "keep configuration files",
default: false,
})
.option("keep-data", {
alias: "d",
type: "boolean",
describe: "keep session data and snapshots",
default: false,
})
.option("dry-run", {
type: "boolean",
describe: "show what would be removed without removing",
default: false,
})
.option("force", {
alias: "f",
type: "boolean",
describe: "skip confirmation prompts",
default: false,
}),
handler: async (args: UninstallArgs) => {
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
prompts.intro("Uninstall OpenCode")
const method = await Installation.method()
prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method)
await showRemovalSummary(targets, method)
if (!args.force && !args.dryRun) {
const confirm = await prompts.confirm({
message: "Are you sure you want to uninstall?",
initialValue: false,
})
if (!confirm || prompts.isCancel(confirm)) {
prompts.outro("Cancelled")
return
}
}
if (args.dryRun) {
prompts.log.warn("Dry run - no changes made")
prompts.outro("Done")
return
}
await executeUninstall(method, targets)
prompts.outro("Done")
},
}
async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise<RemovalTargets> {
const directories: RemovalTargets["directories"] = [
{ path: Global.Path.data, label: "Data", keep: args.keepData },
{ path: Global.Path.cache, label: "Cache", keep: false },
{ path: Global.Path.config, label: "Config", keep: args.keepConfig },
{ path: Global.Path.state, label: "State", keep: false },
]
const shellConfig = method === "curl" ? await getShellConfigFile() : null
const binary = method === "curl" ? process.execPath : null
return { directories, shellConfig, binary }
}
async function showRemovalSummary(targets: RemovalTargets, method: Installation.Method) {
prompts.log.message("The following will be removed:")
for (const dir of targets.directories) {
const exists = await fs
.access(dir.path)
.then(() => true)
.catch(() => false)
if (!exists) continue
const size = await getDirectorySize(dir.path)
const sizeStr = formatSize(size)
const status = dir.keep ? UI.Style.TEXT_DIM + "(keeping)" : ""
const prefix = dir.keep ? "○" : "✓"
prompts.log.info(` ${prefix} ${dir.label}: ${shortenPath(dir.path)} ${UI.Style.TEXT_DIM}(${sizeStr})${status}`)
}
if (targets.binary) {
prompts.log.info(` ✓ Binary: ${shortenPath(targets.binary)}`)
}
if (targets.shellConfig) {
prompts.log.info(` ✓ Shell PATH in ${shortenPath(targets.shellConfig)}`)
}
if (method !== "curl" && method !== "unknown") {
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",
}
prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
}
}
async function executeUninstall(method: Installation.Method, targets: RemovalTargets) {
const spinner = prompts.spinner()
const errors: string[] = []
for (const dir of targets.directories) {
if (dir.keep) {
prompts.log.step(`Skipping ${dir.label} (--keep-${dir.label.toLowerCase()})`)
continue
}
const exists = await fs
.access(dir.path)
.then(() => true)
.catch(() => false)
if (!exists) continue
spinner.start(`Removing ${dir.label}...`)
const err = await fs.rm(dir.path, { recursive: true, force: true }).catch((e) => e)
if (err) {
spinner.stop(`Failed to remove ${dir.label}`, 1)
errors.push(`${dir.label}: ${err.message}`)
continue
}
spinner.stop(`Removed ${dir.label}`)
}
if (targets.shellConfig) {
spinner.start("Cleaning shell config...")
const err = await cleanShellConfig(targets.shellConfig).catch((e) => e)
if (err) {
spinner.stop("Failed to clean shell config", 1)
errors.push(`Shell config: ${err.message}`)
} else {
spinner.stop("Cleaned shell config")
}
}
if (method !== "curl" && method !== "unknown") {
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"],
}
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result = await $`${cmd}`.quiet().nothrow()
if (result.exitCode !== 0) {
spinner.stop(`Package manager uninstall failed`, 1)
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
errors.push(`Package manager: exit code ${result.exitCode}`)
} else {
spinner.stop("Package removed")
}
}
}
if (method === "curl" && targets.binary) {
UI.empty()
prompts.log.message("To finish removing the binary, run:")
prompts.log.info(` rm "${targets.binary}"`)
const binDir = path.dirname(targets.binary)
if (binDir.includes(".opencode")) {
prompts.log.info(` rmdir "${binDir}" 2>/dev/null`)
}
}
if (errors.length > 0) {
UI.empty()
prompts.log.warn("Some operations failed:")
for (const err of errors) {
prompts.log.error(` ${err}`)
}
}
UI.empty()
prompts.log.success("Thank you for using OpenCode!")
}
async function getShellConfigFile(): Promise<string | null> {
const shell = path.basename(process.env.SHELL || "bash")
const home = os.homedir()
const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, ".config")
const configFiles: Record<string, string[]> = {
fish: [path.join(xdgConfig, "fish", "config.fish")],
zsh: [
path.join(home, ".zshrc"),
path.join(home, ".zshenv"),
path.join(xdgConfig, "zsh", ".zshrc"),
path.join(xdgConfig, "zsh", ".zshenv"),
],
bash: [
path.join(home, ".bashrc"),
path.join(home, ".bash_profile"),
path.join(home, ".profile"),
path.join(xdgConfig, "bash", ".bashrc"),
path.join(xdgConfig, "bash", ".bash_profile"),
],
ash: [path.join(home, ".ashrc"), path.join(home, ".profile")],
sh: [path.join(home, ".profile")],
}
const candidates = configFiles[shell] || configFiles.bash
for (const file of candidates) {
const exists = await fs
.access(file)
.then(() => true)
.catch(() => false)
if (!exists) continue
const content = await Bun.file(file)
.text()
.catch(() => "")
if (content.includes("# opencode") || content.includes(".opencode/bin")) {
return file
}
}
return null
}
async function cleanShellConfig(file: string) {
const content = await Bun.file(file).text()
const lines = content.split("\n")
const filtered: string[] = []
let skip = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed === "# opencode") {
skip = true
continue
}
if (skip) {
skip = false
if (trimmed.includes(".opencode/bin") || trimmed.includes("fish_add_path")) {
continue
}
}
if (
(trimmed.startsWith("export PATH=") && trimmed.includes(".opencode/bin")) ||
(trimmed.startsWith("fish_add_path") && trimmed.includes(".opencode"))
) {
continue
}
filtered.push(line)
}
while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") {
filtered.pop()
}
const output = filtered.join("\n") + "\n"
await Bun.write(file, output)
}
async function getDirectorySize(dir: string): Promise<number> {
let total = 0
const walk = async (current: string) => {
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.isDirectory()) {
await walk(full)
continue
}
if (entry.isFile()) {
const stat = await fs.stat(full).catch(() => null)
if (stat) total += stat.size
}
}
}
await walk(dir)
return total
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
function shortenPath(p: string): string {
const home = os.homedir()
if (p.startsWith(home)) {
return p.replace(home, "~")
}
return p
}

View file

@ -6,6 +6,7 @@ import { Log } from "./util/log"
import { AuthCommand } from "./cli/cmd/auth"
import { AgentCommand } from "./cli/cmd/agent"
import { UpgradeCommand } from "./cli/cmd/upgrade"
import { UninstallCommand } from "./cli/cmd/uninstall"
import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
@ -86,6 +87,7 @@ const cli = yargs(hideBin(process.argv))
.command(AuthCommand)
.command(AgentCommand)
.command(UpgradeCommand)
.command(UninstallCommand)
.command(ServeCommand)
.command(WebCommand)
.command(ModelsCommand)