Compare commits

...
Author SHA1 Message Date
Filip Hejmowski b5921c86fc confirm prompts 2026-09-07 23:50:01 +02:00
Filip Hejmowski 827e9a7160 feat: uninstall command 2026-09-07 18:59:50 +02:00
6 changed files with 375 additions and 57 deletions
+24
View File
@@ -72,6 +72,30 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
},
}),
Spec.make("uninstall", {
description: "Uninstall OpenCode and remove its user files",
params: {
keepConfig: Flag.boolean("keep-config").pipe(
Flag.withAlias("c"),
Flag.withDescription("Keep configuration files"),
Flag.withDefault(false),
),
keepData: Flag.boolean("keep-data").pipe(
Flag.withAlias("d"),
Flag.withDescription("Keep session data and snapshots"),
Flag.withDefault(false),
),
dryRun: Flag.boolean("dry-run").pipe(
Flag.withDescription("Show what would be removed without removing it"),
Flag.withDefault(false),
),
force: Flag.boolean("force").pipe(
Flag.withAlias("f"),
Flag.withDescription("Skip confirmation prompts"),
Flag.withDefault(false),
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -0,0 +1,137 @@
import { confirm, intro, log, outro, spinner } from "@clack/prompts"
import { Service } from "@opencode-ai/client/effect/service"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Installation } from "../../services/installation"
import { ServerConnection } from "../../services/server-connection"
import { ServiceConfig } from "../../services/service-config"
import { handlePromptErrors, prompt, requireInteractive } from "../../ui/prompt"
export default Runtime.handler(
Commands.commands.uninstall,
Effect.fn("cli.uninstall")(function* (input) {
intro("Uninstall OpenCode")
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const installation = yield* Installation.make()
const method = yield* installation.method()
log.info(`Installation method: ${method ?? "unknown"}`)
if (!method && !input.dryRun)
return yield* Effect.fail(
new Error(
"Could not detect the installation method. Uninstall OpenCode with your package manager or remove its binary manually.",
),
)
const removal =
method && method !== "curl" && installation.installedPackage
? { method, command: Installation.uninstallCommand(method, installation.installedPackage) }
: undefined
const shell = method === "curl" ? yield* installation.shellChanges() : []
const v1Installed = (!input.keepConfig || !input.keepData) && (yield* installation.v1Installed())
const interactive = !input.force && !input.dryRun
if (interactive)
yield* requireInteractive("Pass --force to uninstall without an interactive terminal, or --dry-run to preview.")
const removeConfig = yield* confirmRemoval({
keep: input.keepConfig,
interactive,
message: v1Installed
? "Delete global OpenCode config? OpenCode v1 also uses it."
: "Delete global OpenCode config?",
})
const removeData = yield* confirmRemoval({
keep: input.keepData,
interactive,
message: v1Installed ? "Delete all OpenCode data? OpenCode v1 also uses it." : "Delete all OpenCode data?",
})
const directories = [
{ path: global.data, label: "Data", keep: !removeData },
{ path: global.cache, label: "Cache", keep: false },
{ path: global.config, label: "Config", keep: !removeConfig },
{ path: global.state, label: "State", keep: false },
]
log.message("Uninstall plan:")
log.info("Stop the local background service")
yield* Effect.forEach(
directories,
(directory) =>
fs
.exists(directory.path)
.pipe(
Effect.flatMap((exists) =>
exists
? Effect.sync(() =>
log.info(`${directory.keep ? "Keep" : "Remove"} ${directory.label}: ${directory.path}`),
)
: Effect.void,
),
),
{ discard: true },
)
shell.forEach((change) => log.info(`Remove installer PATH entry: ${change.path}`))
if (removal) log.info(`Package: ${removal.command.join(" ")}`)
if (method === "curl") log.info(`Binary (manual removal): ${process.execPath}`)
if (v1Installed && (removeConfig || removeData))
log.warn("OpenCode v1 is also installed and uses the files marked for removal.")
if (input.dryRun) {
outro("Dry run - no changes made")
return undefined
}
if (!input.force) {
if (!(yield* prompt(() => confirm({ message: "Proceed with uninstalling OpenCode?", initialValue: false })))) {
outro("Cancelled")
return undefined
}
}
const progress = spinner()
yield* Effect.gen(function* () {
progress.start("Stopping background service...")
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
progress.stop("Background service stopped")
if (removal) {
progress.start(`Running ${removal.command.join(" ")}...`)
yield* installation.uninstall(removal.method)
progress.stop("Package removed")
}
yield* Effect.forEach(
shell,
(change) =>
Effect.gen(function* () {
progress.start(`Cleaning ${change.path}...`)
yield* fs.writeFileString(change.path, change.content)
progress.stop(`Cleaned ${change.path}`)
}),
{ discard: true },
)
yield* Effect.forEach(
directories.filter((directory) => !directory.keep),
(directory) =>
Effect.gen(function* () {
progress.start(`Removing ${directory.label}...`)
yield* fs.remove(directory.path, { recursive: true, force: true })
progress.stop(`Removed ${directory.label}`)
}),
{ discard: true },
)
}).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Uninstall failed", 1))))
if (method === "curl") {
log.message("To finish removing the binary, run:")
log.info(Installation.binaryRemovalCommand())
}
outro("Done")
return undefined
}, handlePromptErrors),
)
function confirmRemoval(input: { readonly keep: boolean; readonly interactive: boolean; readonly message: string }) {
if (input.keep) return Effect.succeed(false)
if (!input.interactive) return Effect.succeed(true)
return prompt(() => confirm({ message: input.message, initialValue: true }))
}
+17 -23
View File
@@ -6,6 +6,16 @@ import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
type Requirements =
| FileSystem.FileSystem
| Global.Service
| Npm.Service
| AppProcess.Service
| Updater.Service
| Config.Service
| Scope.Scope
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -14,29 +24,11 @@ export type Input<Value> =
? Input
: never
type RuntimeHandler = (
input: unknown,
) => Effect.Effect<
void,
unknown,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Requirements>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (
input: Input<Node>,
) => Effect.Effect<
void,
any,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
default: (input: Input<Node>) => Effect.Effect<void, any, Requirements>
}>
type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Npm.Service | Updater.Service | Config.Service | Scope.Scope
>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Requirements>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node>
@@ -67,12 +59,14 @@ export function handlers<const Root extends Spec.Any>(root: Root, handlers: Hand
function add(node: Spec.Any, value: RuntimeHandlers) {
if (typeof value === "function") {
result.push({ spec: node.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases)
result.push({ spec: alias.spec, load: value as () => Promise<{ default: RuntimeHandler }> })
return
}
if (value.$) {
result.push({ spec: node.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases) result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
for (const alias of node.aliases)
result.push({ spec: alias.spec, load: value.$ as () => Promise<{ default: RuntimeHandler }> })
}
for (const [name, child] of Object.entries(node.commands)) add(child, value[name] as RuntimeHandlers)
}
+1
View File
@@ -18,6 +18,7 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
uninstall: () => import("./commands/handlers/uninstall"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
+190
View File
@@ -0,0 +1,190 @@
export * as Installation from "./installation"
import { AppProcess } from "@opencode-ai/util/process"
import { Global } from "@opencode-ai/util/global"
import { Duration, Effect, FileSystem, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "node:path"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type ShellChange = {
readonly path: string
readonly content: string
}
export interface Interface {
readonly installedPackage: string | undefined
readonly method: () => Effect.Effect<Method | undefined>
readonly v1Installed: () => Effect.Effect<boolean>
readonly uninstall: (method: Exclude<Method, "curl">) => Effect.Effect<void, Error>
readonly shellChanges: () => Effect.Effect<ReadonlyArray<ShellChange>>
}
const Manifest = Schema.fromJsonString(
Schema.Struct({
name: Schema.String,
bin: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}),
)
export const make = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(Manifest)))
return Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable)
? manifest.name
: undefined
}).pipe(Effect.orElseSucceed(() => undefined))
const run = Effect.fnUntraced(
function* (command: ReadonlyArray<string>, timeout: Duration.Input = "10 seconds") {
const result = yield* appProcess.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
},
Effect.catch((error) =>
Effect.succeed({
code: 1,
stdout: "",
stderr: error instanceof Error ? error.message : String(error),
}),
),
)
const method = Effect.fn("cli.installation.method")(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return undefined
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const v1Installed = Effect.fn("cli.installation.v1-installed")(
function* () {
const curlBinary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode.exe" : "opencode",
)
if (yield* fs.exists(curlBinary)) return true
// Package-manager installs resolve through PATH (including Bun, nvm, pnpm, Yarn, and Homebrew).
yield* appProcess.run(ChildProcess.make("opencode", ["--version"]), {
timeout: "5 seconds",
maxOutputBytes: 10_000,
maxErrorBytes: 10_000,
})
return true
},
Effect.catch(() => Effect.succeed(false)),
)
const uninstall = Effect.fn("cli.installation.uninstall")(function* (method: Exclude<Method, "curl">) {
if (!installedPackage) return yield* Effect.fail(new Error("Could not identify the installed OpenCode package"))
const result = yield* run(uninstallCommand(method, installedPackage), "5 minutes")
if (result.code !== 0)
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`))
return undefined
})
const shellChanges = Effect.fn("cli.installation.shell-changes")(function* () {
const bin = path.join(global.home, ".opencode", "bin")
const files = yield* fs.readDirectory(bin).pipe(Effect.orElseSucceed(() => []))
// V1 and other installed binaries still need this shared PATH entry.
if (files.some((name) => name !== path.basename(process.execPath))) return []
const shell = path.basename(process.env.SHELL || "bash")
const entry = shell === "fish" ? `fish_add_path ${bin}` : `export PATH=${bin}:$PATH`
const changes = yield* Effect.forEach(shellConfigFiles(shell, global.home), (file) =>
fs.readFileString(file).pipe(
Effect.flatMap((content) => {
const updated = removeShellEntry(content, entry)
return Effect.succeed(updated === undefined ? undefined : { path: file, content: updated })
}),
Effect.orElseSucceed(() => undefined),
),
)
return changes.filter((change) => change !== undefined)
})
return { installedPackage, method, v1Installed, uninstall, shellChanges } satisfies Interface
})
export function uninstallCommand(method: Exclude<Method, "curl">, name: string): [string, ...string[]] {
const commands: Record<Exclude<Method, "curl">, [string, ...string[]]> = {
npm: ["npm", "uninstall", "--global", name],
pnpm: ["pnpm", "remove", "--global", name],
bun: ["bun", "remove", "--global", name],
yarn: ["yarn", "global", "remove", name],
}
return commands[method]
}
export function binaryRemovalCommand(executable = process.execPath) {
return process.platform === "win32"
? `Remove-Item -LiteralPath '${executable.replaceAll("'", "''")}'`
: `rm -- '${executable.replaceAll("'", "'\\''")}'`
}
function shellConfigFiles(shell: string, home: string) {
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config")
const zsh = process.env.ZDOTDIR || home
const candidates: Record<string, string[]> = {
fish: [path.join(home, ".config", "fish", "config.fish")],
zsh: [
path.join(zsh, ".zshrc"),
path.join(zsh, ".zshenv"),
path.join(xdg, "zsh", ".zshrc"),
path.join(xdg, "zsh", ".zshenv"),
],
bash: [
path.join(home, ".bashrc"),
path.join(home, ".bash_profile"),
path.join(home, ".profile"),
path.join(xdg, "bash", ".bashrc"),
path.join(xdg, "bash", ".bash_profile"),
],
ash: [path.join(home, ".ashrc"), path.join(home, ".profile")],
sh: [path.join(home, ".ashrc"), path.join(home, ".profile")],
}
return [...new Set(candidates[shell] ?? [])]
}
function removeShellEntry(content: string, entry: string) {
const newline = content.includes("\r\n") ? "\r\n" : "\n"
const lines = content.split(/\r?\n/)
const marker = lines.findIndex((line, index) => line === "# opencode" && lines[index + 1] === entry)
if (marker === -1) return undefined
const start = marker > 0 && lines[marker - 1] === "" ? marker - 1 : marker
return [...lines.slice(0, start), ...lines.slice(marker + 2)].join(newline)
}
+6 -34
View File
@@ -6,9 +6,10 @@ import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { Installation } from "./installation"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export const methods = Installation.methods
export type Method = Installation.Method
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
@@ -56,17 +57,10 @@ const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installation = yield* Installation.make()
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
return manifest.name
}).pipe(Effect.orElseSucceed(() => undefined))
const installedPackage = installation.installedPackage
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -95,29 +89,7 @@ const make = Effect.gen(function* () {
)
})
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const method = installation.method
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({