| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344 |
- 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
- }
|