Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/cli/src/services/updater.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
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 } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, 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", "brew"] as const
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] 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 }

const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)

export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
Expand Down Expand Up @@ -110,13 +115,20 @@ const make = Effect.gen(function* () {
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
{ method: "vp", command: ["vp", "list", "-g", "--json", installedPackage] },
]
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
return results.find((result) => {
if (result.check.method !== "vp") return result.result.stdout.includes(installedPackage)
// Vite+ repeats the filter in its successful no-match message, so substring detection would be a false positive.
return Option.exists(decodeVpPackages(result.result.stdout), (packages) =>
packages.some((item) => item.name === installedPackage),
)
})?.check.method
})

const removal = (method: Method) => {
Expand All @@ -126,6 +138,7 @@ const make = Effect.gen(function* () {
pnpm: ["pnpm", "remove", "--global", installedPackage],
bun: ["bun", "remove", "--global", installedPackage],
yarn: ["yarn", "global", "remove", installedPackage],
vp: ["vp", "uninstall", "-g", installedPackage],
}
const command = commands[method]
return {
Expand Down Expand Up @@ -194,6 +207,10 @@ const make = Effect.gen(function* () {
],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
vp:
installedPackage && packageName !== installedPackage
? ["vp", "install", "-g", "--force", target]
: ["vp", "update", "-g", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
Expand Down
50 changes: 46 additions & 4 deletions packages/cli/test/updater-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function fixture(
} = () => ({}),
name = "@opencode/cli",
failCleanup = false,
releasePackage = name,
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
Expand All @@ -34,7 +35,7 @@ function fixture(
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,
}),
),
Expand Down Expand Up @@ -106,6 +107,7 @@ const installs = [
command: ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
{ method: "vp", command: ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
] as const

installs.forEach(({ method, command }) => {
Expand All @@ -117,6 +119,25 @@ installs.forEach(({ method, command }) => {
}),
)
})

it.live("vp force-installs a renamed V2 package that replaces the existing binary owner", () =>
Effect.gen(function* () {
const test = yield* fixture(() => ({}), "@opencode/cli", false, "@opencode/cli-node")
yield* test.updater.upgrade("vp", "v2.3.4-beta.1")
expect(test.commands).toEqual([["vp", "install", "-g", "--force", "@opencode/cli-node@2.3.4-beta.1"]])
}),
)

it.live("vp removes the package from its managed global store", () =>
Effect.gen(function* () {
const test = yield* fixture()
const removal = test.updater.removal("vp")
if (!removal) return yield* Effect.die("Expected vp removal command")
expect(removal.command).toEqual(["vp", "uninstall", "-g", "@opencode/cli"])
yield* removal.run
expect(test.commands).toEqual([["vp", "uninstall", "-g", "@opencode/cli"]])
}),
)
;[0, 1].forEach((exitCode) => {
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -200,18 +221,27 @@ it.live("install failures expose stderr and process errors do not report success
expect(missing.commands).toHaveLength(1)
}),
)
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
;(["npm", "pnpm", "bun", "yarn", "vp", undefined] as const).forEach((method) => {
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === method ? "@opencode/cli@2.3.4" : "opencode-ai@1.0.0"),
stdout: Buffer.from(
command.command === method
? method === "vp"
? JSON.stringify([{ name: "@opencode/cli", version: "2.3.4" }])
: "@opencode/cli@2.3.4"
: command.command === "vp"
? "[]"
: "opencode-ai@1.0.0",
),
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode/cli"],
["pnpm", "list", "-g", "--depth=0", "@opencode/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["vp", "list", "-g", "--json", "@opencode/cli"],
])
}),
)
Expand All @@ -225,7 +255,16 @@ it.live("method detection tolerates unavailable package managers", () =>
: { error: new AppProcess.AppProcessError({ command: command.command }) },
)
expect(yield* test.updater.method()).toBe("yarn")
expect(test.commands).toHaveLength(4)
expect(test.commands).toHaveLength(5)
}),
)

it.live("vp detection ignores no-match output that repeats the package name", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "vp" ? "No global packages matching '@opencode/cli'." : ""),
}))
expect(yield* test.updater.method()).toBeUndefined()
}),
)

Expand Down Expand Up @@ -265,13 +304,16 @@ if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-no
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
yield* test.updater.upgrade("vp", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["pnpm", "list", "-g", "--depth=0", "@opencode/cli-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["vp", "list", "-g", "--json", "@opencode/cli-node"],
["npm", "install", "--global", "@opencode/cli-node@2.3.4"],
["pnpm", "add", "--global", "--allow-build=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
["vp", "update", "-g", "@opencode/cli-node@2.3.4"],
])
}),
)
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/test/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ describe("upgrade command", () => {
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
})

test("accepts vp as an explicit installation method", async () => {
const result = await cli(["2.0.0", "--method", "vp"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "vp", version: "2.0.0" }])
})

test("skips the already installed version", async () => {
const result = await cli(["v0.0.0-beta-old"])
expect(result.exitCode).toBe(0)
Expand Down
5 changes: 3 additions & 2 deletions services/www/src/docs/content/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ OpenCode is an open source AI coding agent. It’s available as a terminal-based
{ label: "bun", code: "bun install -g --trust @opencode/cli" },
{ label: "pnpm", code: "pnpm add -g --allow-build=@opencode/cli @opencode/cli" },
{ label: "yarn", code: "yarn global add @opencode/cli" },
{ label: "vite+", code: "vp install -g @opencode/cli" },
{ label: "aur", code: "paru -S opencode-beta" },
]}
/>

The npm package uses a trusted postinstall script to select the native `opencode` binary for your platform. The Bun and pnpm
commands above explicitly allow that script to run.
The npm package uses a postinstall script to select the native `opencode` binary for your platform. The Bun and pnpm commands
explicitly allow the required script; Vite+ requires no additional flag.

Windows package managers are not supported.

Expand Down
Loading