diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 3c1604a0b835..2f878de0b782 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -17,7 +17,7 @@ export const UpgradeCommand = { alias: "m", describe: "installation method to use", type: "string", - choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"], + choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop", "mise"], }) }, handler: async (args: { target?: string; method?: string }) => { diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 4300220255b3..561893edc652 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,7 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" -import { Effect, Layer, Schema, Context, Stream } from "effect" +import { Effect, Layer, Schema, Context, Stream, Option } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" @@ -15,7 +15,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { NpmConfig } from "@opencode-ai/core/npm-config" import { InstallationEvent } from "@opencode-ai/schema/installation-event" -export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" +export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "mise" | "unknown" export type ReleaseType = "patch" | "minor" | "major" @@ -71,6 +71,13 @@ const ChocoPackage = Schema.Struct({ d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), }) const ScoopManifest = NpmPackage +const MiseTool = Schema.Array( + Schema.Struct({ + version: Schema.String, + requested_version: Schema.String, + source: Schema.Struct({ path: Schema.String }), + }), +) export interface Interface { readonly info: () => Effect.Effect @@ -164,6 +171,24 @@ const layer: Layer.Layer new UpgradeFailedError({ stderr: upgradeFailure("curl") })), ) + const upgradeMise = Effect.fnUntraced(function* (target: string) { + const current = yield* run(["mise", "ls", "--current", "--json", "opencode"]) + const tools = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.fromJsonString(MiseTool))(current.stdout)) + const tool = tools?.length === 1 ? tools[0] : undefined + if (current.code !== 0 || !tool?.source.path || tool.version !== InstallationVersion) { + return yield* new UpgradeFailedError({ stderr: "Could not identify the active mise configuration." }) + } + + const requested = tool.requested_version + const fuzzy = /^\d+(?:\.\d+)?$/.test(requested) + const version = fuzzy + ? /^\d+$/.test(requested) + ? target.split(".")[0] + : target.split(".").slice(0, 2).join(".") + : target + return yield* run(["mise", "use", "--path", tool.source.path, fuzzy ? "--fuzzy" : "--pin", `opencode@${version}`]) + }) + const result: Interface = { info: Effect.fn("Installation.info")(function* () { return { @@ -172,9 +197,10 @@ const layer: Layer.Layer Effect.Effect }> = [ { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, @@ -198,11 +224,13 @@ const layer: Layer.Layer { + describe("method", () => { + testEffect(testLayer(() => jsonResponse({}))).effect("detects mise-managed executables", () => + Effect.gen(function* () { + const execPath = process.execPath + process.execPath = path.join("home", ".local", "share", "mise", "installs", "opencode", "1.2.3", "opencode") + const method = yield* Installation.use + .method() + .pipe(Effect.ensuring(Effect.sync(() => (process.execPath = execPath)))) + expect(method).toBe("mise") + }), + ) + }) + describe("latest", () => { testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect( "reads release version from GitHub releases", @@ -182,6 +196,87 @@ describe("installation", () => { }) describe("upgrade", () => { + const miseCalls: Array<{ cmd: string; args: readonly string[] }> = [] + testEffect( + testLayer( + () => jsonResponse({}), + (cmd, args) => { + miseCalls.push({ cmd, args }) + if (cmd === "mise" && args[0] === "ls") + return JSON.stringify([ + { + version: InstallationVersion, + requested_version: "1.2", + source: { path: "/workspace/parent/mise.staging.toml" }, + }, + ]) + return "" + }, + ), + ).effect("updates the active mise source while preserving a fuzzy pin", () => + Effect.gen(function* () { + yield* Installation.use.upgrade("mise", "9.9.9") + expect(miseCalls).toContainEqual({ + cmd: "mise", + args: ["use", "--path", "/workspace/parent/mise.staging.toml", "--fuzzy", "opencode@9.9"], + }) + }), + ) + + const miseAliasCalls: Array<{ cmd: string; args: readonly string[] }> = [] + testEffect( + testLayer( + () => jsonResponse({}), + (cmd, args) => { + miseAliasCalls.push({ cmd, args }) + if (cmd === "mise" && args[0] === "ls") + return JSON.stringify([ + { + version: InstallationVersion, + requested_version: "latest", + source: { path: "/workspace/mise.toml" }, + }, + ]) + return "" + }, + ), + ).effect("honors the target when mise config uses an alias", () => + Effect.gen(function* () { + yield* Installation.use.upgrade("mise", "9.9.9") + expect(miseAliasCalls).toContainEqual({ + cmd: "mise", + args: ["use", "--path", "/workspace/mise.toml", "--pin", "opencode@9.9.9"], + }) + }), + ) + + testEffect( + testLayer( + () => jsonResponse({}), + (cmd, args) => { + if (cmd === "mise" && args[0] === "ls") + return JSON.stringify([ + { + version: InstallationVersion, + requested_version: InstallationVersion, + source: { path: "/workspace/mise.toml" }, + }, + { + version: InstallationVersion, + requested_version: InstallationVersion, + source: { path: "/workspace/other.toml" }, + }, + ]) + return "" + }, + ), + ).effect("fails when mise reports multiple active configurations", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Installation.use.upgrade("mise", "9.9.9")) + expect(error.stderr).toBe("Could not identify the active mise configuration.") + }), + ) + testEffect( testLayer( () => jsonResponse({}),