uninstall.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import type { Argv } from "yargs"
  2. import { UI } from "../ui"
  3. import * as prompts from "@clack/prompts"
  4. import { Installation } from "../../installation"
  5. import { Global } from "../../global"
  6. import { $ } from "bun"
  7. import fs from "fs/promises"
  8. import path from "path"
  9. import os from "os"
  10. interface UninstallArgs {
  11. keepConfig: boolean
  12. keepData: boolean
  13. dryRun: boolean
  14. force: boolean
  15. }
  16. interface RemovalTargets {
  17. directories: Array<{ path: string; label: string; keep: boolean }>
  18. shellConfig: string | null
  19. binary: string | null
  20. }
  21. export const UninstallCommand = {
  22. command: "uninstall",
  23. describe: "uninstall opencode and remove all related files",
  24. builder: (yargs: Argv) =>
  25. yargs
  26. .option("keep-config", {
  27. alias: "c",
  28. type: "boolean",
  29. describe: "keep configuration files",
  30. default: false,
  31. })
  32. .option("keep-data", {
  33. alias: "d",
  34. type: "boolean",
  35. describe: "keep session data and snapshots",
  36. default: false,
  37. })
  38. .option("dry-run", {
  39. type: "boolean",
  40. describe: "show what would be removed without removing",
  41. default: false,
  42. })
  43. .option("force", {
  44. alias: "f",
  45. type: "boolean",
  46. describe: "skip confirmation prompts",
  47. default: false,
  48. }),
  49. handler: async (args: UninstallArgs) => {
  50. UI.empty()
  51. UI.println(UI.logo(" "))
  52. UI.empty()
  53. prompts.intro("Uninstall OpenCode")
  54. const method = await Installation.method()
  55. prompts.log.info(`Installation method: ${method}`)
  56. const targets = await collectRemovalTargets(args, method)
  57. await showRemovalSummary(targets, method)
  58. if (!args.force && !args.dryRun) {
  59. const confirm = await prompts.confirm({
  60. message: "Are you sure you want to uninstall?",
  61. initialValue: false,
  62. })
  63. if (!confirm || prompts.isCancel(confirm)) {
  64. prompts.outro("Cancelled")
  65. return
  66. }
  67. }
  68. if (args.dryRun) {
  69. prompts.log.warn("Dry run - no changes made")
  70. prompts.outro("Done")
  71. return
  72. }
  73. await executeUninstall(method, targets)
  74. prompts.outro("Done")
  75. },
  76. }
  77. async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise<RemovalTargets> {
  78. const directories: RemovalTargets["directories"] = [
  79. { path: Global.Path.data, label: "Data", keep: args.keepData },
  80. { path: Global.Path.cache, label: "Cache", keep: false },
  81. { path: Global.Path.config, label: "Config", keep: args.keepConfig },
  82. { path: Global.Path.state, label: "State", keep: false },
  83. ]
  84. const shellConfig = method === "curl" ? await getShellConfigFile() : null
  85. const binary = method === "curl" ? process.execPath : null
  86. return { directories, shellConfig, binary }
  87. }
  88. async function showRemovalSummary(targets: RemovalTargets, method: Installation.Method) {
  89. prompts.log.message("The following will be removed:")
  90. for (const dir of targets.directories) {
  91. const exists = await fs
  92. .access(dir.path)
  93. .then(() => true)
  94. .catch(() => false)
  95. if (!exists) continue
  96. const size = await getDirectorySize(dir.path)
  97. const sizeStr = formatSize(size)
  98. const status = dir.keep ? UI.Style.TEXT_DIM + "(keeping)" : ""
  99. const prefix = dir.keep ? "○" : "✓"
  100. prompts.log.info(` ${prefix} ${dir.label}: ${shortenPath(dir.path)} ${UI.Style.TEXT_DIM}(${sizeStr})${status}`)
  101. }
  102. if (targets.binary) {
  103. prompts.log.info(` ✓ Binary: ${shortenPath(targets.binary)}`)
  104. }
  105. if (targets.shellConfig) {
  106. prompts.log.info(` ✓ Shell PATH in ${shortenPath(targets.shellConfig)}`)
  107. }
  108. if (method !== "curl" && method !== "unknown") {
  109. const cmds: Record<string, string> = {
  110. npm: "npm uninstall -g opencode-ai",
  111. pnpm: "pnpm uninstall -g opencode-ai",
  112. bun: "bun remove -g opencode-ai",
  113. yarn: "yarn global remove opencode-ai",
  114. brew: "brew uninstall opencode",
  115. choco: "choco uninstall opencode",
  116. scoop: "scoop uninstall opencode",
  117. }
  118. prompts.log.info(` ✓ Package: ${cmds[method] || method}`)
  119. }
  120. }
  121. async function executeUninstall(method: Installation.Method, targets: RemovalTargets) {
  122. const spinner = prompts.spinner()
  123. const errors: string[] = []
  124. for (const dir of targets.directories) {
  125. if (dir.keep) {
  126. prompts.log.step(`Skipping ${dir.label} (--keep-${dir.label.toLowerCase()})`)
  127. continue
  128. }
  129. const exists = await fs
  130. .access(dir.path)
  131. .then(() => true)
  132. .catch(() => false)
  133. if (!exists) continue
  134. spinner.start(`Removing ${dir.label}...`)
  135. const err = await fs.rm(dir.path, { recursive: true, force: true }).catch((e) => e)
  136. if (err) {
  137. spinner.stop(`Failed to remove ${dir.label}`, 1)
  138. errors.push(`${dir.label}: ${err.message}`)
  139. continue
  140. }
  141. spinner.stop(`Removed ${dir.label}`)
  142. }
  143. if (targets.shellConfig) {
  144. spinner.start("Cleaning shell config...")
  145. const err = await cleanShellConfig(targets.shellConfig).catch((e) => e)
  146. if (err) {
  147. spinner.stop("Failed to clean shell config", 1)
  148. errors.push(`Shell config: ${err.message}`)
  149. } else {
  150. spinner.stop("Cleaned shell config")
  151. }
  152. }
  153. if (method !== "curl" && method !== "unknown") {
  154. const cmds: Record<string, string[]> = {
  155. npm: ["npm", "uninstall", "-g", "opencode-ai"],
  156. pnpm: ["pnpm", "uninstall", "-g", "opencode-ai"],
  157. bun: ["bun", "remove", "-g", "opencode-ai"],
  158. yarn: ["yarn", "global", "remove", "opencode-ai"],
  159. brew: ["brew", "uninstall", "opencode"],
  160. choco: ["choco", "uninstall", "opencode"],
  161. scoop: ["scoop", "uninstall", "opencode"],
  162. }
  163. const cmd = cmds[method]
  164. if (cmd) {
  165. spinner.start(`Running ${cmd.join(" ")}...`)
  166. const result =
  167. method === "choco"
  168. ? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
  169. : await $`${cmd}`.quiet().nothrow()
  170. if (result.exitCode !== 0) {
  171. spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
  172. if (
  173. method === "choco" &&
  174. result.stdout.toString("utf8").includes("not running from an elevated command shell")
  175. ) {
  176. prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
  177. } else {
  178. prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)
  179. }
  180. } else {
  181. spinner.stop("Package removed")
  182. }
  183. }
  184. }
  185. if (method === "curl" && targets.binary) {
  186. UI.empty()
  187. prompts.log.message("To finish removing the binary, run:")
  188. prompts.log.info(` rm "${targets.binary}"`)
  189. const binDir = path.dirname(targets.binary)
  190. if (binDir.includes(".opencode")) {
  191. prompts.log.info(` rmdir "${binDir}" 2>/dev/null`)
  192. }
  193. }
  194. if (errors.length > 0) {
  195. UI.empty()
  196. prompts.log.warn("Some operations failed:")
  197. for (const err of errors) {
  198. prompts.log.error(` ${err}`)
  199. }
  200. }
  201. UI.empty()
  202. prompts.log.success("Thank you for using OpenCode!")
  203. }
  204. async function getShellConfigFile(): Promise<string | null> {
  205. const shell = path.basename(process.env.SHELL || "bash")
  206. const home = os.homedir()
  207. const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, ".config")
  208. const configFiles: Record<string, string[]> = {
  209. fish: [path.join(xdgConfig, "fish", "config.fish")],
  210. zsh: [
  211. path.join(home, ".zshrc"),
  212. path.join(home, ".zshenv"),
  213. path.join(xdgConfig, "zsh", ".zshrc"),
  214. path.join(xdgConfig, "zsh", ".zshenv"),
  215. ],
  216. bash: [
  217. path.join(home, ".bashrc"),
  218. path.join(home, ".bash_profile"),
  219. path.join(home, ".profile"),
  220. path.join(xdgConfig, "bash", ".bashrc"),
  221. path.join(xdgConfig, "bash", ".bash_profile"),
  222. ],
  223. ash: [path.join(home, ".ashrc"), path.join(home, ".profile")],
  224. sh: [path.join(home, ".profile")],
  225. }
  226. const candidates = configFiles[shell] || configFiles.bash
  227. for (const file of candidates) {
  228. const exists = await fs
  229. .access(file)
  230. .then(() => true)
  231. .catch(() => false)
  232. if (!exists) continue
  233. const content = await Bun.file(file)
  234. .text()
  235. .catch(() => "")
  236. if (content.includes("# opencode") || content.includes(".opencode/bin")) {
  237. return file
  238. }
  239. }
  240. return null
  241. }
  242. async function cleanShellConfig(file: string) {
  243. const content = await Bun.file(file).text()
  244. const lines = content.split("\n")
  245. const filtered: string[] = []
  246. let skip = false
  247. for (const line of lines) {
  248. const trimmed = line.trim()
  249. if (trimmed === "# opencode") {
  250. skip = true
  251. continue
  252. }
  253. if (skip) {
  254. skip = false
  255. if (trimmed.includes(".opencode/bin") || trimmed.includes("fish_add_path")) {
  256. continue
  257. }
  258. }
  259. if (
  260. (trimmed.startsWith("export PATH=") && trimmed.includes(".opencode/bin")) ||
  261. (trimmed.startsWith("fish_add_path") && trimmed.includes(".opencode"))
  262. ) {
  263. continue
  264. }
  265. filtered.push(line)
  266. }
  267. while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") {
  268. filtered.pop()
  269. }
  270. const output = filtered.join("\n") + "\n"
  271. await Bun.write(file, output)
  272. }
  273. async function getDirectorySize(dir: string): Promise<number> {
  274. let total = 0
  275. const walk = async (current: string) => {
  276. const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => [])
  277. for (const entry of entries) {
  278. const full = path.join(current, entry.name)
  279. if (entry.isDirectory()) {
  280. await walk(full)
  281. continue
  282. }
  283. if (entry.isFile()) {
  284. const stat = await fs.stat(full).catch(() => null)
  285. if (stat) total += stat.size
  286. }
  287. }
  288. }
  289. await walk(dir)
  290. return total
  291. }
  292. function formatSize(bytes: number): string {
  293. if (bytes < 1024) return `${bytes} B`
  294. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
  295. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
  296. return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
  297. }
  298. function shortenPath(p: string): string {
  299. const home = os.homedir()
  300. if (p.startsWith(home)) {
  301. return p.replace(home, "~")
  302. }
  303. return p
  304. }