rari404 2 месяцев назад
Родитель
Сommit
ec27759f90
2 измененных файлов с 346 добавлено и 0 удалено
  1. 344 0
      packages/opencode/src/cli/cmd/uninstall.ts
  2. 2 0
      packages/opencode/src/index.ts

+ 344 - 0
packages/opencode/src/cli/cmd/uninstall.ts

@@ -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
+}

+ 2 - 0
packages/opencode/src/index.ts

@@ -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)