summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-20 12:10:33 -0400
committerGitHub <[email protected]>2026-03-20 12:10:33 -0400
commite78944e9a435ab6121aa12b7857e0b516c9a1b18 (patch)
treec1490527519d3cce5b5fc85fd0756ca922c3264f /packages
parentbfdc38e421aeeee2f4cfa1ac27d222d848a23323 (diff)
downloadopencode-e78944e9a435ab6121aa12b7857e0b516c9a1b18.tar.gz
opencode-e78944e9a435ab6121aa12b7857e0b516c9a1b18.zip
effectify Installation service, drop Effect suffix from namespaces (#18266)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/account/effect.ts32
-rw-r--r--packages/opencode/src/account/index.ts23
-rw-r--r--packages/opencode/src/account/repo.ts8
-rw-r--r--packages/opencode/src/account/schema.ts2
-rw-r--r--packages/opencode/src/auth/effect.ts2
-rw-r--r--packages/opencode/src/auth/index.ts4
-rw-r--r--packages/opencode/src/cli/cmd/account.ts10
-rw-r--r--packages/opencode/src/cli/cmd/upgrade.ts4
-rw-r--r--packages/opencode/src/effect/runtime.ts14
-rw-r--r--packages/opencode/src/installation/index.ts530
-rw-r--r--packages/opencode/src/provider/auth.ts4
-rw-r--r--packages/opencode/src/tool/truncate-effect.ts2
-rw-r--r--packages/opencode/src/tool/truncate.ts2
-rw-r--r--packages/opencode/test/account/service.test.ts14
-rw-r--r--packages/opencode/test/installation/installation.test.ts178
-rw-r--r--packages/opencode/test/tool/truncation.test.ts6
16 files changed, 508 insertions, 327 deletions
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<Option.Option<Account>, AccountError>
- readonly list: () => Effect.Effect<Account[], AccountError>
+ readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
+ readonly list: () => Effect.Effect<Info[], AccountError>
readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
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<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
- return runtime.runSync(AccountEffect.Service.use(f))
+function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
+ return runtime.runSync(S.Service.use(f))
}
-function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
- return runtime.runPromise(AccountEffect.Service.use(f))
+function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
+ 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<Option.Option<Account>, AccountRepoError>
- readonly list: () => Effect.Effect<Account[], AccountRepoError>
+ readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
+ readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo,
Effect.gen(function* () {
- const decode = Schema.decodeUnknownSync(Account)
+ const decode = Schema.decodeUnknownSync(Info)
const query = <A>(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<typeof UserCode>
-export class Account extends Schema.Class<Account>("Account")({
+export class Info extends Schema.Class<Info>("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<Info | undefined, AuthError>
readonly all: () => Effect.Effect<Record<string, Info>, 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<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
- return runtime.runPromise(S.AuthEffect.Service.use(f))
+function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
+ 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<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {
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<ReturnType<typeof method>>
+ 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<typeof Info>
- 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()
-
- 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"]),
- },
- ]
-
- 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
- })
+ export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
+ stderr: Schema.String,
+ }) {}
- 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
- }
- }
+ // 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
- return "unknown"
+ export interface Interface {
+ readonly info: () => Effect.Effect<Info>
+ readonly method: () => Effect.Effect<Method>
+ readonly latest: (method?: Method) => Effect.Effect<string>
+ readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
}
- export const UpgradeFailedError = NamedError.create(
- "UpgradeFailedError",
- z.object({
- stderr: z.string(),
- }),
- )
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
- 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 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<string, string> }) {
+ 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("")),
+ )
- export async function upgrade(method: Method, target: string) {
- let result: Awaited<ReturnType<typeof upgradeCurl>> | 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,
+ const run = Effect.fnUntraced(
+ function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+ 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<string> }> = [
+ { 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
+ }
}
- if (formula.includes("/")) {
- const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
- if (tap.code !== 0) {
- result = tap
- break
+
+ 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
}
- const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
- if (repo.code !== 0) {
- result = repo
- break
+
+ 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
}
- 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
+
+ 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}`)
}
- 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,
+ 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"])
})
- }
- log.info("upgraded", {
- method,
- target,
- stdout: result.stdout.toString(),
- stderr: result.stderr.toString(),
- })
- await Process.text([process.execPath, "--version"], { nothrow: true })
- }
- 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 async function latest(installMethod?: Method) {
- const detectedMethod = installMethod || (await method())
-
- 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
+ return Service.of({
+ info: Effect.fn("Installation.info")(function* () {
+ return {
+ version: VERSION,
+ latest: yield* latestImpl(),
+ }
+ }),
+ method: methodImpl,
+ latest: latestImpl,
+ upgrade: upgradeImpl,
})
- 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)
- }
+ export const defaultLayer = layer.pipe(
+ Layer.provide(FetchHttpClient.layer),
+ Layer.provide(NodeChildProcessSpawner.layer),
+ Layer.provide(NodeFileSystem.layer),
+ Layer.provide(NodePath.layer),
+ )
- 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)
- }
+ // 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<A>(f: (service: Interface) => Effect.Effect<A, any>) {
+ const { runtime } = await import("@/effect/runtime")
+ return runtime.runPromise(Service.use(f))
+ }
- 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 info(): Promise<Info> {
+ return runPromise((svc) => svc.info())
+ }
+
+ export function method(): Promise<Method> {
+ return runPromise((svc) => svc.method())
+ }
+
+ export function latest(installMethod?: Method): Promise<string> {
+ return runPromise((svc) => svc.latest(installMethod))
+ }
+
+ export function upgrade(m: Method, target: string): Promise<void> {
+ 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<typeof HttpClientResponse.fromWeb>[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)
+}
-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
-
- expect(await Installation.latest("unknown")).toBe("1.2.3")
+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)
+}
- 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
-
- expect(await Installation.latest("scoop")).toBe("2.3.4")
+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", () => {
+ describe("latest", () => {
+ test("reads release version from GitHub releases", async () => {
+ const layer = testLayer(() => jsonResponse({ tag_name: "v1.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("strips v prefix from GitHub release tag", async () => {
+ const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))
+
+ 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 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 ""
+ },
+ )
+
+ 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 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 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 ""
},
- )) 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("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)