diff --git a/packages/cli/src/commands/handlers/uninstall.ts b/packages/cli/src/commands/handlers/uninstall.ts index 6cf4fa821c09..2aea28ba1b74 100644 --- a/packages/cli/src/commands/handlers/uninstall.ts +++ b/packages/cli/src/commands/handlers/uninstall.ts @@ -45,8 +45,10 @@ export default Runtime.handler( ) shell.forEach((file) => log.info(` Shell PATH: ${file}`)) if (removal) log.info(` Package: ${removal.command.join(" ")}`) + if (removal?.note) log.info(removal.note) if (method === "curl") log.info(` Binary (manual removal): ${process.execPath}`) - if (!method) log.warn("Could not detect the installation method. Remove the installation manually after cleanup.") + if (!method || (method === "mise" && !removal)) + log.warn("Could not identify an installation to remove. Remove the installation manually after cleanup.") if (input.dryRun) { log.warn("Dry run - no changes made") diff --git a/packages/cli/src/commands/handlers/upgrade.ts b/packages/cli/src/commands/handlers/upgrade.ts index 4b0f7542d980..3be7ec9644d3 100644 --- a/packages/cli/src/commands/handlers/upgrade.ts +++ b/packages/cli/src/commands/handlers/upgrade.ts @@ -29,7 +29,7 @@ export default Runtime.handler( log.info(`From ${OPENCODE_VERSION} → ${version}`) const progress = spinner() progress.start("Upgrading...") - yield* updater.upgrade(method, target).pipe( + yield* updater.upgrade(method, target, { pin: Option.isSome(input.target) }).pipe( Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))), Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))), ) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 0425be04f3e1..75d8bb8f6d80 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -1,13 +1,13 @@ import { Global } from "@opencode/util/global" import { AppProcess } from "@opencode/util/process" import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version" -import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect" +import { Context, Duration, Effect, FileSystem, Layer, Match, Option, Ref, Schedule, Schema } from "effect" 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" -export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const +export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "mise"] as const export type Method = (typeof methods)[number] export type RunResult = { readonly type: "available" | "installed"; readonly version: string } export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string } @@ -18,12 +18,31 @@ export interface Interface { readonly apply: (version: string) => Effect.Effect readonly method: () => Effect.Effect readonly latest: () => Effect.Effect - readonly upgrade: (method: Method, version: string) => Effect.Effect - readonly removal: (method: Method) => - | { readonly command: ReadonlyArray; readonly run: Effect.Effect } + readonly upgrade: ( + method: Method, + version: string, + options?: { readonly pin?: boolean }, + ) => Effect.Effect + readonly removal: ( + method: Method, + ) => + | { readonly command: ReadonlyArray; readonly run: Effect.Effect; readonly note?: string } | undefined } +const MiseTools = Schema.fromJsonString( + Schema.Array( + Schema.Struct({ + version: Schema.String, + requested_version: Schema.String, + install_path: Schema.String, + installed: Schema.Boolean, + active: Schema.Boolean, + source: Schema.Struct({ type: Schema.String, path: Schema.String }), + }), + ), +) + export const pollUpdates = Effect.fnUntraced(function* (input: { readonly check: Effect.Effect readonly initialDelay?: Duration.Input @@ -61,8 +80,9 @@ const make = Effect.gen(function* () { const appProcess = yield* AppProcess.Service const installedVersion = yield* Ref.make(OPENCODE_VERSION) const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") + const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath)) + const mise = miseInstallation(executable) 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 } = yield* fs .readFileString(path.join(directory, "package.json")) @@ -101,6 +121,7 @@ const make = Effect.gen(function* () { }) const method = Effect.fnUntraced(function* () { + if (mise) return "mise" const binary = path.join( global.home, ".opencode", @@ -125,16 +146,30 @@ const make = Effect.gen(function* () { }) const removal = (method: Method) => { - if (method === "curl" || !installedPackage) return undefined - const commands = { - npm: ["npm", "uninstall", "--global", installedPackage], - pnpm: ["pnpm", "remove", "--global", installedPackage], - bun: ["bun", "remove", "--global", installedPackage], - yarn: ["yarn", "global", "remove", installedPackage], - } - const command = commands[method] + const command = Match.value(method).pipe( + Match.when("curl", () => undefined), + Match.when("mise", () => { + if (!mise || !parseReleaseVersion(mise.version)) return undefined + return ["mise", "uninstall", `${mise.tool}@${mise.version}`] + }), + Match.whenOr("npm", "pnpm", "bun", "yarn", (method) => { + if (!installedPackage) return undefined + return { + npm: ["npm", "uninstall", "--global", installedPackage], + pnpm: ["pnpm", "remove", "--global", installedPackage], + bun: ["bun", "remove", "--global", installedPackage], + yarn: ["yarn", "global", "remove", installedPackage], + }[method] + }), + Match.exhaustive, + ) + if (!command) return return { command, + note: + method === "mise" + ? "Only this installed version will be removed. Mise config entries are kept; remove them with mise to prevent reinstallation." + : undefined, run: exec(command, "5 minutes").pipe( Effect.flatMap((result) => result.code === 0 @@ -172,15 +207,91 @@ const make = Effect.gen(function* () { fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), ) - const upgrade = Effect.fnUntraced(function* (method: Method, input: string) { + const miseCurrent = Effect.fnUntraced(function* (name: string) { + const result = yield* exec(["mise", "ls", "--current", "--json", name]) + const tools = Option.getOrUndefined(Schema.decodeUnknownOption(MiseTools)(result.stdout)) + const tool = tools?.length === 1 ? tools[0] : undefined + if ( + result.code !== 0 || + !tool?.installed || + !tool.active || + !path.isAbsolute(tool.install_path) || + !path.isAbsolute(tool.source.path) || + !["mise.toml", ".tool-versions"].includes(tool.source.type) + ) + return yield* Effect.fail(new Error("Could not identify the active mise configuration for this installation.")) + return tool + }) + + const upgradeMise = Effect.fnUntraced(function* (version: string, packageName: string, pin: boolean) { + if (!mise) return yield* Effect.fail(new Error("The running executable is not a recognized mise installation.")) + const tool = yield* miseCurrent(mise.tool) + const directory = yield* fs.realPath(tool.install_path).pipe(Effect.orElseSucceed(() => undefined)) + if (directory !== mise.directory || tool.version !== mise.version) + return yield* Effect.fail( + new Error( + "The active mise version does not own the running executable. Restart OpenCode from the selecting config.", + ), + ) + if (mise.package && mise.package !== packageName) + return yield* Effect.fail( + new Error(`Reinstall npm:${packageName}@${version} with mise to migrate from ${mise.tool}.`), + ) + const source = yield* fs.stat(tool.source.path).pipe(Effect.orElseSucceed(() => undefined)) + if (source?.type !== "File") + return yield* Effect.fail( + new Error("The selecting mise config file no longer exists. Refusing to create a config."), + ) + + const fuzzy = /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/.test(tool.requested_version) + if (tool.requested_version !== "latest" && !fuzzy && !parseReleaseVersion(tool.requested_version)) + return yield* Effect.fail( + new Error(`Unsupported mise version request: ${tool.requested_version}. Use mise to update this selection.`), + ) + const requested = Match.value({ pin, fuzzy, version: tool.requested_version }).pipe( + Match.when({ pin: true }, () => version), + Match.when({ version: "latest" }, () => "latest"), + Match.when({ fuzzy: true }, (request) => + version.split(".").slice(0, request.version.split(".").length).join("."), + ), + Match.orElse(() => version), + ) + const run = Effect.fnUntraced(function* (command: string[]) { + const result = yield* exec(command, "5 minutes") + if (result.code !== 0) return yield* Effect.fail(new Error(result.stderr.trim() || "Failed to update with mise")) + }) + if (!pin && fuzzy) yield* run(["mise", "install", `${mise.tool}@${version}`]) + yield* run([ + "mise", + "use", + "--path", + tool.source.path, + requested === version ? "--pin" : "--fuzzy", + `${mise.tool}@${requested}`, + ]) + const selected = yield* miseCurrent(mise.tool) + if ( + selected.version !== version || + selected.source.path !== tool.source.path || + selected.requested_version !== requested + ) + return yield* Effect.fail( + new Error( + `Mise did not select ${version} in ${tool.source.path}. Check the mise version request and lockfile.`, + ), + ) + }) + + const upgrade = Effect.fnUntraced(function* (method: Method, input: string, options?: { readonly pin?: boolean }) { if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`)) const version = input.trim().replace(/^v/, "") const packageName = (yield* release()).package + if (method === "mise") return yield* upgradeMise(version, packageName, options?.pin ?? false) const target = `${packageName}@${version}` if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) { return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`)) } - const commands: Record, string[]> = { + const commands: Record, string[]> = { // Keep the old package: uninstalling it can unlink the replacement command. npm: [ "npm", @@ -302,5 +413,26 @@ const make = Effect.gen(function* () { export const layer = Layer.effect(Service, make) +function miseInstallation(executable: string) { + const parts = executable.split(path.sep) + const tools = { + opencode: "opencode", + "npm-opencode-cli": "npm:@opencode/cli", + "npm-opencode-cli-node": "npm:@opencode/cli-node", + "npm-opencode-ai-cli": "npm:@opencode-ai/cli", + "npm-opencode-ai-cli-node": "npm:@opencode-ai/cli-node", + } + const index = parts.findIndex((part, index) => part === "installs" && Object.hasOwn(tools, parts[index + 1])) + if (index < 0 || !parts[index + 2] || parts.length <= index + 3) return + const tool = Object.entries(tools).find(([name]) => name === parts[index + 1])?.[1] + if (!tool) return + return { + tool, + package: tool.startsWith("npm:") ? tool.slice(4) : undefined, + version: parts[index + 2], + directory: parts.slice(0, index + 3).join(path.sep), + } +} + export * as Updater from "./updater" export { action, type Action, type Policy } from "./updater-action" diff --git a/packages/cli/test/fixture/upgrade.ts b/packages/cli/test/fixture/upgrade.ts index 2cf2d39f1637..6769fcbb02a1 100644 --- a/packages/cli/test/fixture/upgrade.ts +++ b/packages/cli/test/fixture/upgrade.ts @@ -28,9 +28,9 @@ await Effect.runPromise( ? Effect.fail(new Error("Update check failed")) : Effect.succeed("0.0.0-beta-new") }), - upgrade: (method, version) => + upgrade: (method, version, options) => Effect.suspend(() => { - record({ method, version }) + record({ method, version, ...(method === "mise" ? { pin: options?.pin } : {}) }) return process.env.UPGRADE_TEST_INSTALL_ERROR ? Effect.fail(new Error("Permission denied")) : Effect.void }), }), diff --git a/packages/cli/test/updater-install.test.ts b/packages/cli/test/updater-install.test.ts index dc7085aa4603..8aeabe1db5c7 100644 --- a/packages/cli/test/updater-install.test.ts +++ b/packages/cli/test/updater-install.test.ts @@ -4,7 +4,7 @@ import { AppProcess } from "@opencode/util/process" import { expect, spyOn, test } from "bun:test" import { Effect, FileSystem, PlatformError, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { existsSync } from "node:fs" +import { existsSync, mkdirSync } from "node:fs" import path from "node:path" import { Updater } from "../src/services/updater" import { testEffect } from "../../core/test/lib/effect" @@ -13,28 +13,48 @@ const it = testEffect(NodeServices.layer) declare const OPENCODE_CLI_NAME: string | undefined +type CommandResult = Partial & { error?: AppProcess.AppProcessError } + +function misePaths(input: { name?: string; version?: string; data?: string; binary?: string } = {}) { + const name = input.name ?? "@opencode/cli" + const directory = path.join( + input.data ?? "custom-data", + "installs", + `npm-${name.replace(/^@/, "").replaceAll("/", "-")}`, + input.version ?? "2.0.2", + ) + return { + directory, + executable: path.join(directory, "node_modules", name, "bin", input.binary ?? "opencode"), + } +} + function fixture( - respond: (command: ChildProcess.StandardCommand) => Partial & { - error?: AppProcess.AppProcessError - } = () => ({}), + respond: (command: ChildProcess.StandardCommand, root: string) => CommandResult | Promise = () => ({}), name = "@opencode/cli", failCleanup = false, + location = "package/bin/opencode", + releasePackage = name, ) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" }) - const executable = path.join(root, "package", "bin", "opencode") + const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })) + const executable = path.join(root, location) yield* fs.makeDirectory(path.dirname(executable), { recursive: true }) yield* fs.writeFileString( - path.join(root, "package", "package.json"), - JSON.stringify({ name, bin: { opencode: "bin/opencode" } }), + path.join(path.dirname(path.dirname(executable)), "package.json"), + JSON.stringify({ name, bin: { opencode: `bin/${path.basename(executable)}` } }), + ) + yield* fs.writeFileString( + path.join(root, "mise config.toml"), + '[tools]\n"npm:@opencode/cli" = { version = "latest", allow_builds = true, allow_low_downloads = true }\n', ) // The updater uses global fetch; scope this replacement to each install test. yield* Effect.acquireRelease( Effect.sync(() => spyOn(globalThis, "fetch").mockImplementation( - Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), { + Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: releasePackage } }), { preconnect: fetch.preconnect, }), ), @@ -76,12 +96,12 @@ function fixture( AppProcess.Service.of({ ...spawner, run: (command) => - Effect.suspend(() => { - if (command._tag !== "StandardCommand") return Effect.die("Unexpected piped install command") + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected piped install command") commands.push([command.command, ...command.args]) - const result = respond(command) - if (result.error) return Effect.fail(result.error) - return Effect.succeed({ + const result = yield* Effect.promise(async () => respond(command, root)) + if (result.error) return yield* Effect.fail(result.error) + return { command: command.command, exitCode: 0, stdout: Buffer.alloc(0), @@ -89,13 +109,13 @@ function fixture( stdoutTruncated: false, stderrTruncated: false, ...result, - }) + } }), runStream: () => Stream.die("Unexpected streaming install command"), }), ), ) - return { updater, commands, global, fs } + return { updater, commands, global, fs, root } }) } @@ -138,6 +158,94 @@ installs.forEach(({ method, command }) => { ) }) +if (Bun.which("mise")) { + ;["latest", "2", "2.0", "2.0.2"].forEach((requested) => { + it.live(`real mise preserves tool options when upgrading ${requested} in an isolated config`, () => + Effect.gen(function* () { + const registry = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => + Response.json({ + name: "@opencode/cli", + "dist-tags": { latest: "2.3.4" }, + versions: { + "2.0.2": { name: "@opencode/cli", version: "2.0.2", dist: { tarball: "http://127.0.0.1/unused" } }, + "2.3.4": { name: "@opencode/cli", version: "2.3.4", dist: { tarball: "http://127.0.0.1/unused" } }, + }, + time: { "2.0.2": "2025-01-01T00:00:00Z", "2.3.4": "2025-01-02T00:00:00Z" }, + }), + }), + ), + (server) => Effect.promise(() => server.stop(true)), + ) + const test = yield* fixture( + async (command, root) => { + // Supply an already-installed target instead of downloading a real release. + // All discovery, selection and config writes still run through mise itself. + if (command.args[0] === "use" || command.args[0] === "install") + mkdirSync(path.join(root, misePaths({ version: "2.3.4" }).directory), { recursive: true }) + const child = Bun.spawn([command.command, ...command.args], { + cwd: root, + env: { + PATH: process.env.PATH, + HOME: path.join(root, "home"), + MISE_DATA_DIR: path.join(root, "custom-data"), + MISE_CACHE_DIR: path.join(root, "cache"), + MISE_STATE_DIR: path.join(root, "state"), + MISE_CONFIG_DIR: path.join(root, "config"), + npm_config_registry: registry.url.toString(), + MISE_USE_VERSIONS_HOST: "false", + MISE_PIN: "1", + MISE_MINIMUM_RELEASE_AGE: "0", + }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + }) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).arrayBuffer(), + new Response(child.stderr).arrayBuffer(), + ]) + expect(exitCode, Buffer.from(stderr).toString()).toBe(0) + return { exitCode, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) } + }, + "@opencode/cli", + false, + misePaths().executable, + ) + yield* test.fs.writeFileString( + path.join(test.root, path.dirname(misePaths().directory), ".mise.backend.toml"), + 'short = "npm:@opencode/cli"\nfull = "npm:@opencode/cli"\nexplicit_backend = true\n', + ) + const config = path.join(test.root, "config/config.toml") + yield* test.fs.makeDirectory(path.dirname(config), { recursive: true }) + yield* test.fs.makeDirectory(path.join(test.root, "home"), { recursive: true }) + yield* test.fs.writeFileString( + config, + `[tools]\n"npm:@opencode/cli" = { version = "${requested}", allow_builds = ["@opencode/cli"], allow_low_downloads = true }\n`, + ) + yield* test.updater.apply("2.3.4") + // Mise versions may serialize backend booleans as strings; both preserve the option. + expect( + [true, "true"].map((allow_low_downloads) => ({ + tools: { + "npm:@opencode/cli": { + version: requested === "2.0" ? "2.3" : requested === "2.0.2" ? "2.3.4" : requested, + allow_builds: ["@opencode/cli"], + allow_low_downloads, + }, + }, + })), + ).toContainEqual(Bun.TOML.parse(yield* test.fs.readFileString(config))) + }), + ) + }) +} + it.live("bun ignores install cache cleanup failures", () => Effect.gen(function* () { const test = yield* fixture(() => ({}), "@opencode/cli", true) @@ -229,6 +337,303 @@ it.live("method detection tolerates unavailable package managers", () => }), ) +function miseTool(root: string, requested = "latest", version = "2.0.2") { + return { + version, + requested_version: requested, + install_path: path.join(root, misePaths({ version }).directory), + source: { type: "mise.toml", path: path.join(root, "mise config.toml") }, + installed: true, + active: true, + } +} + +;[ + misePaths().executable, + misePaths({ name: "@opencode/cli-node", binary: "opencode.exe" }).executable, + "custom-data/installs/opencode/2.0.2/bin/opencode", + ".local/share/mise/installs/npm-opencode-ai-cli/2.0.2/bin/opencode", +].forEach((location) => { + it.live(`detects the resolved mise installation before global npm: ${location}`, () => + Effect.gen(function* () { + const test = yield* fixture(() => ({ stdout: Buffer.from("@opencode/cli") }), "@opencode/cli", false, location) + expect(yield* test.updater.method()).toBe("mise") + expect(test.commands).toEqual([]) + }), + ) +}) + +it.live("mise detects platform binaries without a CLI package manifest", () => + Effect.gen(function* () { + const test = yield* fixture(() => ({}), "@opencode/cli-darwin-arm64", false, misePaths().executable) + expect(yield* test.updater.method()).toBe("mise") + expect(test.commands).toEqual([]) + }), +) + +it.live("global npm inside mise-managed Node remains an npm installation", () => + Effect.gen(function* () { + const test = yield* fixture( + (command) => ({ stdout: Buffer.from(command.command === "npm" ? "@opencode/cli" : "") }), + "@opencode/cli", + false, + "custom-data/installs/node/24.0.0/lib/node_modules/@opencode/cli/bin/opencode", + ) + expect(yield* test.updater.method()).toBe("npm") + }), +) +;["bun", "node", "npm-opencode-cli-other"].forEach((tool) => { + it.live(`does not mistake mise-managed ${tool} for a mise OpenCode install`, () => + Effect.gen(function* () { + const test = yield* fixture(() => ({}), tool, false, `custom-data/installs/${tool}/2.0.2/bin/${tool}`) + expect(yield* test.updater.method()).toBeUndefined() + expect(test.commands).toEqual([]) + }), + ) +}) +;[ + { before: "latest", after: "latest", pin: false, install: false }, + { before: "2", after: "2", pin: false, install: true }, + { before: "2.0", after: "2.3", pin: false, install: true }, + { before: "2.0.2", after: "2.3.4", pin: false, install: false }, + { before: "latest", after: "2.3.4", pin: true, install: false }, + { before: "2.0", after: "2.3.4", pin: true, install: false }, +].forEach((selection) => { + it.live(`mise selects ${selection.after} from ${selection.before} with explicit pin=${selection.pin}`, () => + Effect.gen(function* () { + const used: string[] = [] + const test = yield* fixture( + (command, root) => { + if (command.args[0] === "use") used.push("use") + return { + stdout: Buffer.from( + JSON.stringify([ + miseTool(root, used.length ? selection.after : selection.before, used.length ? "2.3.4" : "2.0.2"), + ]), + ), + } + }, + "@opencode/cli", + false, + misePaths().executable, + ) + const config = path.join(test.root, "mise config.toml") + const original = yield* test.fs.readFileString(config) + yield* test.updater.upgrade("mise", "v2.3.4", { pin: selection.pin }) + expect(test.commands).toEqual([ + ["mise", "ls", "--current", "--json", "npm:@opencode/cli"], + ...(selection.install ? [["mise", "install", "npm:@opencode/cli@2.3.4"]] : []), + [ + "mise", + "use", + "--path", + config, + selection.after === "2.3.4" ? "--pin" : "--fuzzy", + `npm:@opencode/cli@${selection.after}`, + ], + ["mise", "ls", "--current", "--json", "npm:@opencode/cli"], + ]) + // Only mise owns config edits, including allow_builds and allow_low_downloads. + expect(yield* test.fs.readFileString(config)).toBe(original) + }), + ) +}) + +it.live("shared updater apply detects mise and verifies successful activation", () => + Effect.gen(function* () { + const used: string[] = [] + const test = yield* fixture( + (command, root) => { + if (command.args[0] === "use") used.push("use") + return { stdout: Buffer.from(JSON.stringify([miseTool(root, "latest", used.length ? "2.3.4" : "2.0.2")])) } + }, + "@opencode/cli", + false, + misePaths().executable, + ) + yield* test.updater.apply("2.3.4") + expect(test.commands.every((command) => command[0] === "mise")).toBe(true) + expect(used).toEqual(["use"]) + }), +) +;[".tool-versions", "mise.staging.toml"].forEach((file) => { + it.live(`mise updates the selecting ${file} instead of creating a local config`, () => + Effect.gen(function* () { + const used: string[] = [] + const test = yield* fixture( + (command, root) => { + if (command.args[0] === "use") used.push("use") + return { + stdout: Buffer.from( + JSON.stringify([ + { + ...miseTool(root, used.length ? "2.3.4" : "2.0.2", used.length ? "2.3.4" : "2.0.2"), + source: { type: file === ".tool-versions" ? file : "mise.toml", path: path.join(root, file) }, + }, + ]), + ), + } + }, + "@opencode/cli", + false, + misePaths().executable, + ) + yield* test.fs.writeFileString(path.join(test.root, file), "fixture") + yield* test.updater.apply("2.3.4") + expect(test.commands[1]).toEqual([ + "mise", + "use", + "--path", + path.join(test.root, file), + "--pin", + "npm:@opencode/cli@2.3.4", + ]) + expect(yield* test.fs.exists(path.join(test.root, "mise.toml"))).toBe(false) + }), + ) +}) + +const ambiguousMise: { name: string; tools: (root: string) => unknown }[] = [ + { name: "no active selection", tools: () => [] }, + { name: "multiple active versions", tools: (root) => [miseTool(root), miseTool(root, "2", "2.1.0")] }, + { name: "missing metadata", tools: () => [{}] }, + { name: "wrong version", tools: (root) => [{ ...miseTool(root), version: "2.0.1" }] }, + { name: "another install directory", tools: (root) => [{ ...miseTool(root), install_path: root }] }, + { + name: "relative install path", + tools: (root) => [{ ...miseTool(root), install_path: "installs/npm-opencode-cli/2.0.2" }], + }, + { name: "inactive version", tools: (root) => [{ ...miseTool(root), active: false }] }, + { name: "uninstalled version", tools: (root) => [{ ...miseTool(root), installed: false }] }, + { + name: "environment override", + tools: (root) => [ + { ...miseTool(root), source: { type: "environment", key: "MISE_NPM_OPENCODE_CLI_VERSION", value: "2.0.2" } }, + ], + }, + { + name: "unknown source", + tools: (root) => [{ ...miseTool(root), source: { type: "unknown", path: path.join(root, "mise config.toml") } }], + }, + { + name: "relative config", + tools: (root) => [{ ...miseTool(root), source: { type: "mise.toml", path: "mise.toml" } }], + }, + { + name: "missing config", + tools: (root) => [{ ...miseTool(root), source: { type: "mise.toml", path: path.join(root, "missing.toml") } }], + }, + { + name: "directory instead of config", + tools: (root) => [{ ...miseTool(root), source: { type: "mise.toml", path: root } }], + }, + { name: "unsupported alias", tools: (root) => [miseTool(root, "stable")] }, + { name: "path request", tools: (root) => [miseTool(root, "path:/other/install")] }, +] + +ambiguousMise.forEach((input) => { + it.live(`mise refuses ${input.name} before modifying anything`, () => + Effect.gen(function* () { + const test = yield* fixture( + (command, root) => ({ stdout: Buffer.from(JSON.stringify(input.tools(root))) }), + "@opencode/cli", + false, + misePaths().executable, + ) + yield* test.updater.apply("2.3.4").pipe(Effect.flip) + expect(test.commands).toEqual([["mise", "ls", "--current", "--json", "npm:@opencode/cli"]]) + expect(yield* test.fs.exists(path.join(test.root, "missing.toml"))).toBe(false) + }), + ) +}) +;["missing", "nonzero", "malformed"].forEach((failure) => { + it.live(`mise ${failure} discovery never falls back to npm`, () => + Effect.gen(function* () { + const test = yield* fixture( + (command, root) => ({ + ...(failure === "missing" ? { error: new AppProcess.AppProcessError({ command: "mise" }) } : {}), + exitCode: failure === "nonzero" ? 1 : 0, + stdout: Buffer.from(failure === "malformed" ? "not json" : JSON.stringify([miseTool(root)])), + }), + "@opencode/cli", + false, + misePaths().executable, + ) + expect(yield* test.updater.method()).toBe("mise") + yield* test.updater.apply("2.3.4").pipe(Effect.flip) + expect(test.commands).toEqual([["mise", "ls", "--current", "--json", "npm:@opencode/cli"]]) + }), + ) +}) +;["install", "use", "activation"].forEach((failure) => { + it.live(`mise ${failure} failure is not reported as installed`, () => + Effect.gen(function* () { + const test = yield* fixture( + (command, root) => ({ + stdout: Buffer.from(JSON.stringify([miseTool(root, "2")])), + exitCode: command.args[0] === failure ? 1 : 0, + stderr: Buffer.from(`mise ${failure} failed`), + }), + "@opencode/cli", + false, + misePaths().executable, + ) + const error = yield* test.updater.apply("2.3.4").pipe(Effect.flip) + expect(error.message).toContain(failure === "activation" ? "Mise did not select" : `mise ${failure} failed`) + expect(test.commands).toHaveLength(failure === "install" ? 2 : failure === "use" ? 3 : 4) + }), + ) +}) + +it.live("explicit mise requires ownership and refuses package migration", () => + Effect.gen(function* () { + const unmanaged = yield* fixture() + yield* unmanaged.updater.upgrade("mise", "2.3.4").pipe(Effect.flip) + expect(unmanaged.commands).toEqual([]) + expect(unmanaged.updater.removal("mise")).toBeUndefined() + const migrated = yield* fixture( + (command, root) => ({ stdout: Buffer.from(JSON.stringify([miseTool(root)])) }), + "@opencode/cli", + false, + misePaths().executable, + "@opencode-ai/cli", + ) + const error = yield* migrated.updater.upgrade("mise", "2.3.4").pipe(Effect.flip) + expect(error.message).toContain("Reinstall npm:@opencode-ai/cli@2.3.4 with mise") + expect(migrated.commands).toHaveLength(1) + }), +) +;[0, 1].forEach((exitCode) => { + it.live(`mise removal targets only the running version and handles exit ${exitCode}`, () => + Effect.gen(function* () { + const test = yield* fixture( + () => ({ exitCode, stderr: Buffer.from("mise uninstall failed") }), + "@opencode/cli", + false, + misePaths().executable, + ) + const removal = test.updater.removal("mise") + if (!removal) return yield* Effect.die("Missing mise removal plan") + expect(removal.command).toEqual(["mise", "uninstall", "npm:@opencode/cli@2.0.2"]) + expect(removal.note).toContain("Mise config entries are kept") + expect(test.commands).toEqual([]) + const result = yield* removal.run.pipe(Effect.flip, Effect.option) + expect(result._tag).toBe(exitCode === 0 ? "None" : "Some") + if (result._tag === "Some") expect(result.value.message).toBe("mise uninstall failed") + expect(test.commands).toEqual([["mise", "uninstall", "npm:@opencode/cli@2.0.2"]]) + }), + ) +}) + +it.live("mise removal refuses an unresolved version directory", () => + Effect.gen(function* () { + const test = yield* fixture(() => ({}), "@opencode/cli", false, misePaths({ version: "latest" }).executable) + expect(yield* test.updater.method()).toBe("mise") + expect(test.updater.removal("mise")).toBeUndefined() + expect(test.commands).toEqual([]) + }), +) + test("Node distribution honors the compile-time CLI name", async () => { const child = Bun.spawn( [ diff --git a/packages/cli/test/upgrade.test.ts b/packages/cli/test/upgrade.test.ts index a9b773db5a0d..fe4fe974f62d 100644 --- a/packages/cli/test/upgrade.test.ts +++ b/packages/cli/test/upgrade.test.ts @@ -35,6 +35,15 @@ describe("upgrade command", () => { expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }]) }) + test("mise is a method choice and explicit targets request a pin", async () => { + const result = await cli(["2.3.4", "--method", "mise"]) + expect(result.exitCode).toBe(0) + expect(result.events).toEqual([{ method: "mise", version: "2.3.4", pin: true }]) + const latest = await cli([], { UPGRADE_TEST_METHOD: "mise" }) + expect(latest.exitCode).toBe(0) + expect(latest.events).toEqual(["method", "latest", { method: "mise", version: "0.0.0-beta-new", pin: false }]) + }) + test("skips the already installed version", async () => { const result = await cli(["v0.0.0-beta-old"]) expect(result.exitCode).toBe(0)