diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts index 2f1304d50..8686ef42a 100644 --- a/packages/opencode/src/account/effect.ts +++ b/packages/opencode/src/account/effect.ts @@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo" import { type AccountError, AccessToken, - Account, AccountID, DeviceCode, + Info, RefreshToken, AccountServiceError, Login, @@ -24,10 +24,30 @@ import { UserCode, } from "./schema" -export * from "./schema" +export { + AccountID, + type AccountError, + AccountRepoError, + AccountServiceError, + AccessToken, + RefreshToken, + DeviceCode, + UserCode, + Info, + Org, + OrgID, + Login, + PollSuccess, + PollPending, + PollSlow, + PollExpired, + PollDenied, + PollError, + PollResult, +} from "./schema" export type AccountOrgs = { - account: Account + account: Info orgs: readonly Org[] } @@ -108,10 +128,10 @@ const mapAccountServiceError = ), ) -export namespace AccountEffect { +export namespace Account { export interface Interface { - readonly active: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect + readonly active: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect readonly orgsByAccount: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 3a9d758e2..753b80c5f 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,31 +1,24 @@ import { Effect, Option } from "effect" -import { - Account as AccountSchema, - type AccountError, - type AccessToken, - AccountID, - AccountEffect, - OrgID, -} from "./effect" +import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect" export { AccessToken, AccountID, OrgID } from "./effect" import { runtime } from "@/effect/runtime" -function runSync(f: (service: AccountEffect.Interface) => Effect.Effect) { - return runtime.runSync(AccountEffect.Service.use(f)) +function runSync(f: (service: S.Interface) => Effect.Effect) { + return runtime.runSync(S.Service.use(f)) } -function runPromise(f: (service: AccountEffect.Interface) => Effect.Effect) { - return runtime.runPromise(AccountEffect.Service.use(f)) +function runPromise(f: (service: S.Interface) => Effect.Effect) { + return runtime.runPromise(S.Service.use(f)) } export namespace Account { - export const Account = AccountSchema - export type Account = AccountSchema + export const Info = Model + export type Info = Model - export function active(): Account | undefined { + export function active(): Info | undefined { return Option.getOrUndefined(runSync((service) => service.active())) } diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index 5caf1a3b9..1659546a2 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect" import { Database } from "@/storage/db" import { AccountStateTable, AccountTable } from "./account.sql" -import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema" +import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" export type AccountRow = (typeof AccountTable)["$inferSelect"] @@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1 export namespace AccountRepo { export interface Service { - readonly active: () => Effect.Effect, AccountRepoError> - readonly list: () => Effect.Effect + readonly active: () => Effect.Effect, AccountRepoError> + readonly list: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> @@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service = Layer.effect( AccountRepo, Effect.gen(function* () { - const decode = Schema.decodeUnknownSync(Account) + const decode = Schema.decodeUnknownSync(Info) const query = (f: (db: DbClient) => A) => Effect.try({ diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 9b31c4ba6..830b203a9 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe( ) export type UserCode = Schema.Schema.Type -export class Account extends Schema.Class("Account")({ +export class Info extends Schema.Class("Account")({ id: AccountID, email: Schema.String, url: Schema.String, diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts index e03ad9586..14a970807 100644 --- a/packages/opencode/src/auth/effect.ts +++ b/packages/opencode/src/auth/effect.ts @@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json") const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) -export namespace AuthEffect { +export namespace Auth { export interface Interface { readonly get: (providerID: string) => Effect.Effect readonly all: () => Effect.Effect, AuthError> diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 6f588e937..411d9dccc 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -5,8 +5,8 @@ import * as S from "./effect" export { OAUTH_DUMMY_KEY } from "./effect" -function runPromise(f: (service: S.AuthEffect.Interface) => Effect.Effect) { - return runtime.runPromise(S.AuthEffect.Service.use(f)) +function runPromise(f: (service: S.Auth.Interface) => Effect.Effect) { + return runtime.runPromise(S.Auth.Service.use(f)) } export namespace Auth { diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index c2b47da11..fb702c95a 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { runtime } from "@/effect/runtime" -import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect" +import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect" import { type AccountError } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -17,7 +17,7 @@ const isActiveOrgChoice = ( ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* AccountEffect.Service + const service = yield* Account.Service yield* Prompt.intro("Log in") const login = yield* service.login(url) @@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { }) const logoutEffect = Effect.fn("logout")(function* (email?: string) { - const service = yield* AccountEffect.Service + const service = yield* Account.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -98,7 +98,7 @@ interface OrgChoice { } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* AccountEffect.Service + const service = yield* Account.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("Not logged in") @@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () { }) const orgsEffect = Effect.fn("orgs")(function* () { - const service = yield* AccountEffect.Service + const service = yield* Account.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 4438fa3b8..018205663 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -58,10 +58,10 @@ export const UpgradeCommand = { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { // necessary because choco only allows install/upgrade in elevated terminals - if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) { + if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) { prompts.log.error("Please run the terminal as Administrator and try again") } else { - prompts.log.error(err.data.stderr) + prompts.log.error(err.stderr) } } else if (err instanceof Error) prompts.log.error(err.message) prompts.outro("Done") diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index f52203b22..e6f1f3262 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,17 +1,19 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import { AccountEffect } from "@/account/effect" -import { AuthEffect } from "@/auth/effect" +import { Account } from "@/account/effect" +import { Auth } from "@/auth/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" -import { TruncateEffect } from "@/tool/truncate-effect" +import { Installation } from "@/installation" +import { Truncate } from "@/tool/truncate-effect" import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( - AccountEffect.defaultLayer, // - TruncateEffect.defaultLayer, + Account.defaultLayer, // + Installation.defaultLayer, + Truncate.defaultLayer, Instances.layer, - ).pipe(Layer.provideMerge(AuthEffect.layer)), + ).pipe(Layer.provideMerge(Auth.layer)), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 92a3bfc79..308fd0b07 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,12 +1,13 @@ -import { BusEvent } from "@/bus/bus-event" +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { withTransientReadRetry } from "@/util/effect-http-client" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" import z from "zod" -import { NamedError } from "@opencode-ai/util/error" -import { Log } from "../util/log" -import { iife } from "@/util/iife" +import { BusEvent } from "@/bus/bus-event" import { Flag } from "../flag/flag" -import { Process } from "@/util/process" -import { buffer } from "node:stream/consumers" +import { Log } from "../util/log" declare global { const OPENCODE_VERSION: string @@ -16,39 +17,7 @@ declare global { export namespace Installation { const log = Log.create({ service: "installation" }) - async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { - return Process.text(cmd, { - cwd: opts.cwd, - env: opts.env, - nothrow: true, - }).then((x) => x.text) - } - - async function upgradeCurl(target: string) { - const body = await fetch("https://opencode.ai/install").then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.text() - }) - const proc = Process.spawn(["bash"], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - VERSION: target, - }, - }) - if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available") - proc.stdin.end(body) - const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) - return { - code, - stdout, - stderr, - } - } - - export type Method = Awaited> + export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export const Event = { Updated: BusEvent.define( @@ -75,12 +44,9 @@ export namespace Installation { }) export type Info = z.infer - export async function info() { - return { - version: VERSION, - latest: await latest(), - } - } + export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" + export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" + export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` export function isPreview() { return CHANNEL !== "latest" @@ -90,214 +56,306 @@ export namespace Installation { return CHANNEL === "local" } - export async function method() { - if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" - if (process.execPath.includes(path.join(".local", "bin"))) return "curl" - const exec = process.execPath.toLowerCase() + export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { + stderr: Schema.String, + }) {} - const checks = [ - { - name: "npm" as const, - command: () => text(["npm", "list", "-g", "--depth=0"]), - }, - { - name: "yarn" as const, - command: () => text(["yarn", "global", "list"]), - }, - { - name: "pnpm" as const, - command: () => text(["pnpm", "list", "-g", "--depth=0"]), - }, - { - name: "bun" as const, - command: () => text(["bun", "pm", "ls", "-g"]), - }, - { - name: "brew" as const, - command: () => text(["brew", "list", "--formula", "opencode"]), - }, - { - name: "scoop" as const, - command: () => text(["scoop", "list", "opencode"]), - }, - { - name: "choco" as const, - command: () => text(["choco", "list", "--limit-output", "opencode"]), - }, - ] + // Response schemas for external version APIs + const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) + const NpmPackage = Schema.Struct({ version: Schema.String }) + const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) }) + const BrewInfoV2 = Schema.Struct({ + formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })), + }) + const ChocoPackage = Schema.Struct({ + d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), + }) + const ScoopManifest = NpmPackage - checks.sort((a, b) => { - const aMatches = exec.includes(a.name) - const bMatches = exec.includes(b.name) - if (aMatches && !bMatches) return -1 - if (!aMatches && bMatches) return 1 - return 0 - }) - - for (const check of checks) { - const output = await check.command() - const installedName = - check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" - if (output.includes(installedName)) { - return check.name - } - } - - return "unknown" + export interface Interface { + readonly info: () => Effect.Effect + readonly method: () => Effect.Effect + readonly latest: (method?: Method) => Effect.Effect + readonly upgrade: (method: Method, target: string) => Effect.Effect } - export const UpgradeFailedError = NamedError.create( - "UpgradeFailedError", - z.object({ - stderr: z.string(), + export class Service extends ServiceMap.Service()("@opencode/Installation") {} + + export const layer: Layer.Layer< + Service, + never, + HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner + > = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const text = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const out = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + yield* handle.exitCode + return out + }, + Effect.scoped, + Effect.catch(() => Effect.succeed("")), + ) + + const run = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, stdout, stderr } + }, + Effect.scoped, + Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), + ) + + const getBrewFormula = Effect.fnUntraced(function* () { + const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) + if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" + const coreFormula = yield* text(["brew", "list", "--formula", "opencode"]) + if (coreFormula.includes("opencode")) return "opencode" + return "opencode" + }) + + const upgradeCurl = Effect.fnUntraced( + function* (target: string) { + const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install")) + const body = yield* response.text + const bodyBytes = new TextEncoder().encode(body) + const proc = ChildProcess.make("bash", [], { + stdin: Stream.make(bodyBytes), + env: { VERSION: target }, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const [stdout, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, stdout, stderr } + }, + Effect.scoped, + Effect.orDie, + ) + + const methodImpl = Effect.fn("Installation.method")(function* () { + if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method + if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method + const exec = process.execPath.toLowerCase() + + const checks: Array<{ name: Method; command: () => Effect.Effect }> = [ + { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, + { name: "yarn", command: () => text(["yarn", "global", "list"]) }, + { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) }, + { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) }, + { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) }, + { name: "scoop", command: () => text(["scoop", "list", "opencode"]) }, + { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) }, + ] + + checks.sort((a, b) => { + const aMatches = exec.includes(a.name) + const bMatches = exec.includes(b.name) + if (aMatches && !bMatches) return -1 + if (!aMatches && bMatches) return 1 + return 0 + }) + + for (const check of checks) { + const output = yield* check.command() + const installedName = + check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" + if (output.includes(installedName)) { + return check.name + } + } + + return "unknown" as Method + }) + + const latestImpl = Effect.fn("Installation.latest")( + function* (installMethod?: Method) { + const detectedMethod = installMethod || (yield* methodImpl()) + + if (detectedMethod === "brew") { + const formula = yield* getBrewFormula() + if (formula.includes("/")) { + const infoJson = yield* text(["brew", "info", "--json=v2", formula]) + const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson) + return info.formulae[0].versions.stable + } + const response = yield* httpOk.execute( + HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) + return data.versions.stable + } + + if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { + const r = (yield* text(["npm", "config", "get", "registry"])).trim() + const reg = r || "https://registry.npmjs.org" + const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg + const channel = CHANNEL + const response = yield* httpOk.execute( + HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson), + ) + const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response) + return data.version + } + + if (detectedMethod === "choco") { + const response = yield* httpOk.execute( + HttpClientRequest.get( + "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })), + ) + const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response) + return data.d.results[0].Version + } + + if (detectedMethod === "scoop") { + const response = yield* httpOk.execute( + HttpClientRequest.get( + "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })), + ) + const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response) + return data.version + } + + const response = yield* httpOk.execute( + HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) + return data.tag_name.replace(/^v/, "") + }, + Effect.orDie, + ) + + const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { + let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined + switch (m) { + case "curl": + result = yield* upgradeCurl(target) + break + case "npm": + result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) + break + case "pnpm": + result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) + break + case "bun": + result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`]) + break + case "brew": { + const formula = yield* getBrewFormula() + const env = { HOMEBREW_NO_AUTO_UPDATE: "1" } + if (formula.includes("/")) { + const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env }) + if (tap.code !== 0) { + result = tap + break + } + const repo = yield* text(["brew", "--repo", "anomalyco/tap"]) + const dir = repo.trim() + if (dir) { + const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env }) + if (pull.code !== 0) { + result = pull + break + } + } + } + result = yield* run(["brew", "upgrade", formula], { env }) + break + } + case "choco": + result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) + break + case "scoop": + result = yield* run(["scoop", "install", `opencode@${target}`]) + break + default: + throw new Error(`Unknown method: ${m}`) + } + if (!result || result.code !== 0) { + const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" + return yield* new UpgradeFailedError({ stderr }) + } + log.info("upgraded", { + method: m, + target, + stdout: result.stdout, + stderr: result.stderr, + }) + yield* text([process.execPath, "--version"]) + }) + + return Service.of({ + info: Effect.fn("Installation.info")(function* () { + return { + version: VERSION, + latest: yield* latestImpl(), + } + }), + method: methodImpl, + latest: latestImpl, + upgrade: upgradeImpl, + }) }), ) - async function getBrewFormula() { - const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) - if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" - const coreFormula = await text(["brew", "list", "--formula", "opencode"]) - if (coreFormula.includes("opencode")) return "opencode" - return "opencode" + export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(NodeFileSystem.layer), + Layer.provide(NodePath.layer), + ) + + // Legacy adapters — dynamic import avoids circular dependency since + // foundational modules (db.ts, provider/models.ts) import Installation + // at load time, and runtime transitively loads those same modules. + async function runPromise(f: (service: Interface) => Effect.Effect) { + const { runtime } = await import("@/effect/runtime") + return runtime.runPromise(Service.use(f)) } - export async function upgrade(method: Method, target: string) { - let result: Awaited> | undefined - switch (method) { - case "curl": - result = await upgradeCurl(target) - break - case "npm": - result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "pnpm": - result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "bun": - result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "brew": { - const formula = await getBrewFormula() - const env = { - HOMEBREW_NO_AUTO_UPDATE: "1", - ...process.env, - } - if (formula.includes("/")) { - const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) - if (tap.code !== 0) { - result = tap - break - } - const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true }) - if (repo.code !== 0) { - result = repo - break - } - const dir = repo.text.trim() - if (dir) { - const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true }) - if (pull.code !== 0) { - result = pull - break - } - } - } - result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true }) - break - } - - case "choco": - result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true }) - break - case "scoop": - result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true }) - break - default: - throw new Error(`Unknown method: ${method}`) - } - if (!result || result.code !== 0) { - const stderr = - method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || "" - throw new UpgradeFailedError({ - stderr: stderr, - }) - } - log.info("upgraded", { - method, - target, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), - }) - await Process.text([process.execPath, "--version"], { nothrow: true }) + export function info(): Promise { + return runPromise((svc) => svc.info()) } - export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" - export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" - export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` + export function method(): Promise { + return runPromise((svc) => svc.method()) + } - export async function latest(installMethod?: Method) { - const detectedMethod = installMethod || (await method()) + export function latest(installMethod?: Method): Promise { + return runPromise((svc) => svc.latest(installMethod)) + } - if (detectedMethod === "brew") { - const formula = await getBrewFormula() - if (formula.includes("/")) { - const infoJson = await text(["brew", "info", "--json=v2", formula]) - const info = JSON.parse(infoJson) - const version = info.formulae?.[0]?.versions?.stable - if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) - return version - } - return fetch("https://formulae.brew.sh/api/formula/opencode.json") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.versions.stable) - } - - if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { - const registry = await iife(async () => { - const r = (await text(["npm", "config", "get", "registry"])).trim() - const reg = r || "https://registry.npmjs.org" - return reg.endsWith("/") ? reg.slice(0, -1) : reg - }) - const channel = CHANNEL - return fetch(`${registry}/opencode-ai/${channel}`) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.version) - } - - if (detectedMethod === "choco") { - return fetch( - "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", - { headers: { Accept: "application/json;odata=verbose" } }, - ) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.d.results[0].Version) - } - - if (detectedMethod === "scoop") { - return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { - headers: { Accept: "application/json" }, - }) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.version) - } - - return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.tag_name.replace(/^v/, "")) + export function upgrade(m: Method, target: string): Promise { + return runPromise((svc) => svc.upgrade(m, target)) } } diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 5204b5fb8..fe6409776 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -106,7 +106,7 @@ export namespace ProviderAuth { export const layer = Layer.effect( Service, Effect.gen(function* () { - const auth = yield* Auth.AuthEffect.Service + const auth = yield* Auth.Auth.Service const hooks = yield* Effect.promise(async () => { const mod = await import("../plugin") const plugins = await mod.Plugin.list() @@ -213,7 +213,7 @@ export namespace ProviderAuth { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer)) + export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer)) export async function methods() { return runPromiseInstance(Service.use((svc) => svc.methods())) diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts index a263cd294..1b4c6577f 100644 --- a/packages/opencode/src/tool/truncate-effect.ts +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -9,7 +9,7 @@ import { Log } from "../util/log" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" -export namespace TruncateEffect { +export namespace Truncate { const log = Log.create({ service: "truncation" }) const RETENTION = Duration.days(7) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 159b2d1d5..171054638 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,6 +1,6 @@ import type { Agent } from "../agent/agent" import { runtime } from "@/effect/runtime" -import { TruncateEffect as S } from "./truncate-effect" +import { Truncate as S } from "./truncate-effect" export namespace Truncate { export const MAX_LINES = S.MAX_LINES diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 098e00de5..e0d0530fb 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" -import { AccountEffect } from "../../src/account/effect" +import { Account } from "../../src/account/effect" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../lib/effect" @@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard( const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const live = (client: HttpClient.HttpClient) => - AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) const json = (req: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( @@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) => ) const poll = (body: unknown, status = 400) => - AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) + Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) it.effect("orgsByAccount groups orgs per account", () => Effect.gen(function* () { @@ -97,7 +97,7 @@ it.effect("orgsByAccount groups orgs per account", () => }), ) - const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) + const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ [AccountID.make("user-1"), [OrgID.make("org-1")]], @@ -135,7 +135,7 @@ it.effect("token refresh persists the new token", () => ), ) - const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) + const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(token)).toBeDefined() expect(String(Option.getOrThrow(token))).toBe("at_new") @@ -178,7 +178,7 @@ it.effect("config sends the selected org header", () => }), ) - const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe( + const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe( Effect.provide(live(client)), ) @@ -209,7 +209,7 @@ it.effect("poll stores the account and first org on success", () => ), ) - const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client))) + const res = yield* Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client))) expect(res._tag).toBe("PollSuccess") if (res._tag === "PollSuccess") { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index a7cfe50d9..aa3ce9587 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,47 +1,155 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" -const fetch0 = globalThis.fetch +const encoder = new TextEncoder() -afterEach(() => { - globalThis.fetch = fetch0 -}) +function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) { + const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request)))) + return Layer.succeed(HttpClient.HttpClient, client) +} + +function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") { + const spawner = ChildProcessSpawner.make((command) => { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + const output = handler(std?.command ?? "", std?.args ?? []) + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + }), + ) + }) + return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner) +} + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) +} + +function testLayer( + httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, + spawnHandler?: (cmd: string, args: readonly string[]) => string, +) { + return Installation.layer.pipe( + Layer.provide(mockHttpClient(httpHandler)), + Layer.provide(mockSpawner(spawnHandler)), + ) +} describe("installation", () => { - test("reads release version from GitHub releases", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v1.2.3" }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch + describe("latest", () => { + test("reads release version from GitHub releases", async () => { + const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" })) - expect(await Installation.latest("unknown")).toBe("1.2.3") - }) + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.2.3") + }) - test("reads scoop manifest versions", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ version: "2.3.4" }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch + test("strips v prefix from GitHub release tag", async () => { + const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" })) - expect(await Installation.latest("scoop")).toBe("2.3.4") - }) + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("4.0.0-beta.1") + }) - test("reads chocolatey feed versions", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - d: { - results: [{ Version: "3.4.5" }], - }, - }), - { - status: 200, - headers: { "content-type": "application/json" }, + test("reads npm registry versions", async () => { + const layer = testLayer( + () => jsonResponse({ version: "1.5.0" }), + (cmd, args) => { + if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n" + return "" }, - )) as unknown as typeof fetch + ) - expect(await Installation.latest("choco")).toBe("3.4.5") + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.5.0") + }) + + test("reads npm registry versions for bun method", async () => { + const layer = testLayer( + () => jsonResponse({ version: "1.6.0" }), + () => "", + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.6.0") + }) + + test("reads scoop manifest versions", async () => { + const layer = testLayer(() => jsonResponse({ version: "2.3.4" })) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("2.3.4") + }) + + test("reads chocolatey feed versions", async () => { + const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } })) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("3.4.5") + }) + + test("reads brew formulae API versions", async () => { + const layer = testLayer( + () => jsonResponse({ versions: { stable: "2.0.0" } }), + (cmd, args) => { + // getBrewFormula: return core formula (no tap) + if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return "" + if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode" + return "" + }, + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("2.0.0") + }) + + test("reads brew tap info JSON via CLI", async () => { + const brewInfoJson = JSON.stringify({ + formulae: [{ versions: { stable: "2.1.0" } }], + }) + const layer = testLayer( + () => jsonResponse({}), // HTTP not used for tap formula + (cmd, args) => { + if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) + return "opencode" + if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson + return "" + }, + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("2.1.0") + }) }) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index a00e07e69..032f0bfee 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "../../src/tool/truncate" -import { TruncateEffect } from "../../src/tool/truncate-effect" +import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect" import { Identifier } from "../../src/id/id" import { Process } from "../../src/util/process" import { Filesystem } from "../../src/util/filesystem" @@ -139,7 +139,7 @@ describe("Truncate", () => { describe("cleanup", () => { const DAY_MS = 24 * 60 * 60 * 1000 - const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer)) + const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer)) it.effect("deletes files older than 7 days and preserves recent files", () => Effect.gen(function* () { @@ -152,7 +152,7 @@ describe("Truncate", () => { yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") - yield* TruncateEffect.Service.use((s) => s.cleanup()) + yield* TruncateSvc.Service.use((s) => s.cleanup()) expect(yield* fs.exists(old)).toBe(false) expect(yield* fs.exists(recent)).toBe(true)