Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker 03de58cfe3 fix(desktop): restore server CORS policy 2026-08-11 17:05:21 +00:00
32 changed files with 430 additions and 593 deletions
+2 -1
View File
@@ -422,7 +422,8 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
defaultModel: {
providerID: defaultModel.providerID,
id: defaultModel.id,
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
variant:
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
},
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
defaultModeID: defaultAgent.id,
@@ -3,38 +3,6 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service lifecycle", () => {
test("does not persist the first catalog variant when no explicit default exists", async () => {
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
await using fixture = makeACPFixture({
models: [model],
defaultModel: model,
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
return Response.json({
data: makeSession("ses_default_variant", {
model: { providerID: model.providerID, id: model.id },
}),
})
}
return undefined
},
})
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
expect(fixture.requests).toContainEqual({
method: "POST",
path: "/api/session",
query: {},
body: {
location: { directory: "/workspace" },
agent: "build",
model: { providerID: "test", id: "second-model" },
},
})
expect(currentValue(created, "effort")).toBe("none")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
await using fixture = makeACPFixture({
fetch(request) {
+11 -59
View File
@@ -4,7 +4,7 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
@@ -154,67 +154,19 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
fs.readDirectoryEntries(path.join(directory, source)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) =>
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
),
),
))
.flat()
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
})
}
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
return Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
return Option.none<string>()
})
}
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
const files = yield* fs
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
cwd: directory,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
})
}
+86 -15
View File
@@ -1,32 +1,58 @@
import { Database, type SQLQueryBindings } from "bun:sqlite"
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
const ATTR_DB_SYSTEM_NAME = "db.system.name"
interface Config extends Sqlite.ClientConfig {
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
type TypeId = typeof TypeId
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly export: Effect.Effect<Uint8Array, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
readonly updateValues: never
}
interface Config {
readonly filename: string
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly disableWAL?: boolean
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
interface SqliteConnection extends Connection {
readonly export: Effect.Effect<Uint8Array, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as Database
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.query<Record<string, unknown>, SQLQueryBindings[]>(query)
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try {
return Effect.succeed(statement.all(...(params as SQLQueryBindings[])) ?? [])
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
} catch (cause) {
return Effect.fail(
new SqlError({
@@ -38,11 +64,11 @@ const make = (options: Config) =>
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
const statement = native.query<unknown, SQLQueryBindings[]>(query)
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
try {
return Effect.succeed(statement.values(...(params as SQLQueryBindings[])) ?? [])
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
} catch (cause) {
return Effect.fail(
new SqlError({
@@ -52,7 +78,25 @@ const make = (options: Config) =>
}
})
const connection = Sqlite.makeConnection(run, runValues, {
const connection = identity<SqliteConnection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
export: Effect.try({
try: () => native.serialize(),
catch: (cause) =>
@@ -60,7 +104,7 @@ const make = (options: Config) =>
reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }),
}),
}),
loadExtension: (path: string) =>
loadExtension: (path) =>
Effect.try({
try: () => native.loadExtension(path),
catch: (cause) =>
@@ -70,10 +114,37 @@ const make = (options: Config) =>
}),
})
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
export: Effect.flatMap(acquirer, (_) => _.export),
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
}))
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
export: Effect.flatMap(acquirer, (_) => _.export),
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
},
)
return client
})
const nativeLayer = (config: Config) =>
+78 -9
View File
@@ -1,14 +1,26 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
const ATTR_DB_SYSTEM_NAME = "db.system.name"
interface Config extends Sqlite.ClientConfig {
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
type TypeId = typeof TypeId
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
readonly updateValues: never
}
interface Config {
readonly filename: string
readonly readonly?: boolean
readonly create?: boolean
@@ -16,12 +28,24 @@ interface Config extends Sqlite.ClientConfig {
readonly disableWAL?: boolean
readonly timeout?: number
readonly allowExtension?: boolean
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
interface SqliteConnection extends Connection {
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DatabaseSync
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
@@ -55,8 +79,26 @@ const make = (options: Config) =>
}
})
const connection = Sqlite.makeConnection(run, runValues, {
loadExtension: (path: string) =>
const connection = identity<SqliteConnection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
loadExtension: (path) =>
Effect.try({
try: () => native.loadExtension(path),
catch: (cause) =>
@@ -66,9 +108,36 @@ const make = (options: Config) =>
}),
})
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
}))
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
},
)
return client
})
const nativeLayer = (config: Config) =>
+1 -93
View File
@@ -1,100 +1,8 @@
export * as Sqlite from "./sqlite"
import { Context, Effect, Fiber, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import type { SqlError } from "effect/unstable/sql/SqlError"
import { Context } from "effect"
import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
export interface ClientConfig {
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
type Run = (
query: string,
params?: ReadonlyArray<unknown>,
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, SqlError>
type RunValues = (
query: string,
params?: ReadonlyArray<unknown>,
) => Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>
export const makeConnection = <Extensions extends object>(run: Run, runValues: RunValues, extensions: Extensions) =>
identity<Connection & Extensions>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
...extensions,
})
export const makeClient = <
Config extends ClientConfig,
SqliteConnection extends Connection,
const TypeId extends string,
Extensions extends object,
>(
options: Config,
connection: SqliteConnection,
typeId: TypeId,
extensions: (acquirer: Effect.Effect<SqliteConnection, SqlError, Scope.Scope>) => Extensions,
) =>
Effect.gen(function* () {
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
return Object.assign(
yield* SqlClient.make({
acquirer,
compiler: Statement.makeCompilerSqlite(options.transformQueryNames),
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
["db.system.name", "sqlite"],
],
transformRows,
}),
{
[typeId]: typeId,
config: options,
...extensions(acquirer),
},
) as SqlClient.SqlClient &
Record<TypeId, TypeId> & {
readonly config: Config
readonly updateValues: never
} & Extensions
})
-4
View File
@@ -17,7 +17,6 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigPluginSource } from "../config/plugin/source"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { Environment } from "../environment"
@@ -77,7 +76,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const pluginSources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
@@ -114,7 +112,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(ConfigPluginSource.Service, pluginSources),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
@@ -158,7 +155,6 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const AlibabaPlugin = createProviderPlugin({
export const AlibabaPlugin = define({
id: "opencode.provider.alibaba",
package: "@ai-sdk/alibaba",
load: async (options) => {
const { createAlibaba } = await import("@ai-sdk/alibaba")
return createAlibaba(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
evt.sdk = mod.createAlibaba(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const CoherePlugin = createProviderPlugin({
export const CoherePlugin = define({
id: "opencode.provider.cohere",
package: "@ai-sdk/cohere",
load: async (options) => {
const { createCohere } = await import("@ai-sdk/cohere")
return createCohere(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
evt.sdk = mod.createCohere(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const DeepInfraPlugin = createProviderPlugin({
export const DeepInfraPlugin = define({
id: "opencode.provider.deepinfra",
package: "@ai-sdk/deepinfra",
load: async (options) => {
const { createDeepInfra } = await import("@ai-sdk/deepinfra")
return createDeepInfra(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
evt.sdk = mod.createDeepInfra(evt.options)
}),
)
}),
})
@@ -1,22 +0,0 @@
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
import { Effect } from "effect"
export function createProviderPlugin(input: {
readonly id: string
readonly package: string
readonly load: (options: AISDKHooks["sdk"]["options"]) => Promise<unknown>
}) {
return define({
id: input.id,
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== input.package) return
evt.sdk = yield* Effect.promise(() => input.load(evt.options))
}),
)
}),
})
}
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const GatewayPlugin = createProviderPlugin({
export const GatewayPlugin = define({
id: "opencode.provider.gateway",
package: "@ai-sdk/gateway",
load: async (options) => {
const { createGateway } = await import("@ai-sdk/gateway")
return createGateway(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
evt.sdk = mod.createGateway(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const GroqPlugin = createProviderPlugin({
export const GroqPlugin = define({
id: "opencode.provider.groq",
package: "@ai-sdk/groq",
load: async (options) => {
const { createGroq } = await import("@ai-sdk/groq")
return createGroq(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
evt.sdk = mod.createGroq(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const MistralPlugin = createProviderPlugin({
export const MistralPlugin = define({
id: "opencode.provider.mistral",
package: "@ai-sdk/mistral",
load: async (options) => {
const { createMistral } = await import("@ai-sdk/mistral")
return createMistral(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
evt.sdk = mod.createMistral(evt.options)
}),
)
}),
})
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const PerplexityPlugin = createProviderPlugin({
export const PerplexityPlugin = define({
id: "opencode.provider.perplexity",
package: "@ai-sdk/perplexity",
load: async (options) => {
const { createPerplexity } = await import("@ai-sdk/perplexity")
return createPerplexity(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
evt.sdk = mod.createPerplexity(evt.options)
}),
)
}),
})
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const TogetherAIPlugin = createProviderPlugin({
export const TogetherAIPlugin = define({
id: "opencode.provider.togetherai",
package: "@ai-sdk/togetherai",
load: async (options) => {
const { createTogetherAI } = await import("@ai-sdk/togetherai")
return createTogetherAI(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/togetherai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
evt.sdk = mod.createTogetherAI(evt.options)
}),
)
}),
})
+13 -7
View File
@@ -1,10 +1,16 @@
import { createProviderPlugin } from "./factory"
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
export const VenicePlugin = createProviderPlugin({
export const VenicePlugin = define({
id: "opencode.provider.venice",
package: "venice-ai-sdk-provider",
load: async (options) => {
const { createVenice } = await import("venice-ai-sdk-provider")
return createVenice(options)
},
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
Effect.fn(function* (evt) {
if (evt.package !== "venice-ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
evt.sdk = mod.createVenice(evt.options)
}),
)
}),
})
+31 -5
View File
@@ -6,8 +6,12 @@ import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { Skill } from "../skill"
import { ConfigPluginSource } from "../config/plugin/source"
import { Config } from "../config"
import { Location } from "../location"
import { FSUtil } from "@opencode-ai/util/fs-util"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -68,10 +72,32 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
.toSorted()
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
return Effect.succeed(
(entry.info.plugins ?? []).map((item) => {
const ref = typeof item === "string" ? { package: item } : item
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package)
return ref.package
}),
)
}
if (entry.type !== "directory") return Effect.succeed([])
return fs
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
}).pipe(Effect.map((items) => items.flat().toSorted()))
})
function terminal() {
+15 -3
View File
@@ -2,7 +2,7 @@ export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema"
import { Database } from "./database/database"
@@ -13,7 +13,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectDirectories } from "./project/directories"
import { ProjectSchema } from "./project/schema"
import { ProjectTable, upsertProject } from "./project/sql"
import { ProjectTable } from "./project/sql"
export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID
@@ -98,7 +98,19 @@ const layer = Layer.effect(
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* upsertProject(tx, project)
const vcs = project.vcs?.type
yield* tx
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
if (!project.vcs) return
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
if (project.directory === project.canonical) return
-25
View File
@@ -1,14 +1,8 @@
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { isNotNull, isNull, ne, or } from "drizzle-orm"
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { absoluteArrayColumn, absoluteColumn } from "../database/path"
import { Timestamps } from "../database/schema.sql"
import type { AbsolutePath } from "../schema"
import { ProjectSchema } from "./schema"
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: absoluteColumn().notNull(),
@@ -39,22 +33,3 @@ export const ProjectDirectoryTable = sqliteTable(
},
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
)
export function upsertProject(
db: DatabaseClient | Transaction,
project: { readonly id: ProjectSchema.ID; readonly canonical: AbsolutePath; readonly vcs?: ProjectSchema.Vcs },
) {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
}
+18 -3
View File
@@ -3,7 +3,7 @@ export * from "./session/schema"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
import { Workspace } from "./workspace"
import { Model } from "./model"
@@ -21,7 +21,7 @@ import { Agent } from "./agent"
import { Money } from "@opencode-ai/schema/money"
import { App } from "./app"
import { Slug } from "./util/slug"
import { upsertProject } from "./project/sql"
import { ProjectTable } from "./project/sql"
import path from "path"
import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
@@ -309,7 +309,22 @@ const layer = Layer.effect(
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
+18 -3
View File
@@ -3,7 +3,7 @@ export * as SessionTransfer from "./transfer"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { eq } from "drizzle-orm"
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import path from "path"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -12,7 +12,7 @@ import { Bus } from "../bus"
import { Database } from "../database/database"
import { Location } from "../location"
import { Project } from "../project"
import { upsertProject } from "../project/sql"
import { ProjectTable } from "../project/sql"
import { AbsolutePath, RelativePath } from "../schema"
import { Session } from "../session"
import { Slug } from "../util/slug"
@@ -49,7 +49,22 @@ const layer = Layer.effect(
const sessions = yield* Session.Service
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
const persistProject = (project: Project.Resolved) => {
const vcs = project.vcs?.type
return db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
.onConflictDoUpdate({
target: ProjectTable.id,
set: { worktree: project.canonical, vcs: vcs ?? null },
setWhere: or(
ne(ProjectTable.worktree, project.canonical),
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
),
})
.run()
.pipe(Effect.orDie)
}
return Service.of({
export: Effect.fn("SessionTransfer.export")(function* (input) {
-87
View File
@@ -168,75 +168,6 @@ describe("PluginSupervisor config", () => {
),
)
it.live("loads auto-discovered plugin package entrypoints in order", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("package-exports")
expect(ids).toContain("package-module")
expect(ids).toContain("package-main")
expect(ids).toContain("package-index")
}),
false,
async (directory) => {
await Promise.all([
writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
writeDiscoveredPackage(
directory,
"module",
{ exports: "./missing.js", module: "./entry.js" },
{ "entry.js": "package-module" },
),
writeDiscoveredPackage(
directory,
"main",
{ exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
{ "entry.js": "package-main" },
),
writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
])
},
),
)
it.live("keeps auto-discovered package entrypoints inside the package directory", () =>
withLocation(
undefined,
Effect.gen(function* () {
yield* ready()
const plugins = yield* Plugin.Service
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
expect(ids).toContain("contained-fallback")
expect(ids).toContain("symlink-fallback")
expect(ids).not.toContain("escaped-entrypoint")
}),
false,
async (directory) => {
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
await writeDiscoveredPackage(
directory,
"contained",
{ exports: "../../escape.js" },
{ "index.js": "contained-fallback" },
)
await writeDiscoveredPackage(
directory,
"symlink",
{ exports: "./entry.js" },
{ "index.js": "symlink-fallback" },
)
await fs.symlink(
path.join(directory, ".opencode", "escape.js"),
path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
)
},
),
)
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
Effect.gen(function* () {
const sdk = yield* SdkPlugins.Service
@@ -458,21 +389,3 @@ export default Plugin.define({
})
`
}
function discoveredPlugin(id: string) {
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
}
async function writeDiscoveredPackage(
directory: string,
name: string,
manifest: Record<string, unknown> | undefined,
files: Record<string, string>,
) {
const plugin = path.join(directory, ".opencode", "plugins", name)
await fs.mkdir(plugin, { recursive: true })
await Promise.all([
...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
])
}
+16 -34
View File
@@ -1,18 +1,18 @@
import { describe, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Effect, Layer, Stream } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Effect, Stream } from "effect"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
const it = testEffect(AppNodeBuilder.build(Skill.node))
const sources = (operations: readonly ConfigPluginSource.Operation[] = []) =>
Layer.succeed(
ConfigPluginSource.Service,
ConfigPluginSource.Service.of({ operations: () => Effect.succeed(operations), changes: () => Stream.never }),
)
describe("SkillPlugin.Plugin", () => {
it.effect("registers built-in skills", () =>
@@ -27,7 +27,15 @@ describe("SkillPlugin.Plugin", () => {
reload: skill.reload,
},
}),
).pipe(Effect.provide(sources()))
).pipe(
Effect.provide(Config.testLayer()),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
),
Effect.provide(AppNodeBuilder.build(FSUtil.node)),
Effect.provide(NodeFileSystem.layer),
)
const skills = yield* skill.list()
const report = skills.find((item) => item.id === "report")
@@ -50,30 +58,4 @@ describe("SkillPlugin.Plugin", () => {
expect(report?.content).toContain("- install/channel: beta")
}),
)
it.effect("reports canonical configured plugin sources with existing labels and ordering", () =>
Effect.gen(function* () {
const skill = yield* Skill.Service
yield* SkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: skill.transform,
reload: skill.reload,
},
}),
)
const report = (yield* skill.list()).find((item) => item.id === "report")
expect(report?.content).toContain("- Active plugins: -disabled, local.ts, package-plugin, package-plugin")
}).pipe(
Effect.provide(
sources([
{ type: "add", target: "package-plugin", options: {} },
{ type: "remove", target: "disabled" },
{ type: "add", target: "local.ts", options: {}, mtime: 1 },
{ type: "add", target: "package-plugin", options: { enabled: true } },
]),
),
),
)
})
@@ -37,7 +37,6 @@ export async function startBackgroundCli(logger: Logger) {
url: service.url,
username: service.auth.username,
password: service.auth.password,
version,
}
}
+3 -5
View File
@@ -134,10 +134,10 @@ const main = Effect.gen(function* () {
initCrashReporter()
const wslServers = createWslServersController(
CHANNEL === "beta" ? null : app.getVersion(),
async (distro, version, channel) => {
app.getVersion(),
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, version, channel, {
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
@@ -146,7 +146,6 @@ const main = Effect.gen(function* () {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
channel: CHANNEL,
},
)
const stopSidecars = async () => wslServers.stopAll()
@@ -312,7 +311,6 @@ const main = Effect.gen(function* () {
logger.log("starting v2 background service")
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
if (CHANNEL === "beta") wslServers.setCliVersion(sidecar.version)
yield* Deferred.succeed(serverReady, {
url: sidecar.url,
username: sidecar.username,
-8
View File
@@ -208,12 +208,6 @@ export function createMainWindow(id: string = randomUUID()) {
wireWindowRecovery(win, id)
wireNavigationPolicy(win)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
callback({ requestHeaders })
})
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
const { responseHeaders = {} } = details
addRendererHeaders(details.url, responseHeaders)
@@ -500,8 +494,6 @@ function isTrustedRendererUrl(value?: string) {
}
function addRendererHeaders(value: string, headers: Record<string, any>) {
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
}
+8 -29
View File
@@ -264,22 +264,18 @@ export async function installWslDistro(name: string, opts?: RunWslOptions) {
)
}
export async function installWslOpencode(version: string, channel: string, distro: string, opts?: RunWslOptions) {
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
return runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(["bash", "-lc", wslOpencodeInstallCommand(version, channel)], distro),
wslArgs(
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
distro,
),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
)
}
export function wslOpencodeInstallCommand(version: string, channel: string) {
if (channel === "beta") {
return `npm install --global --no-audit --no-fund ${shellEscape(`@opencode-ai/cli@${version}`)}`
}
return `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,
@@ -311,22 +307,11 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
}
}
export async function resolveWslOpencode(distro: string, channel: string, opts?: RunWslOptions) {
if (channel !== "beta") {
return firstLine(
(
await runWslSh(
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
distro,
opts,
)
).stdout,
)
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
return firstLine(
(
await runWslSh(
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//"); export PATH; command -v opencode2 || true',
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
distro,
opts,
)
@@ -336,13 +321,7 @@ export async function resolveWslOpencode(distro: string, channel: string, opts?:
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
return parseWslOpencodeVersion(firstLine(result.stdout))
}
export function parseWslOpencodeVersion(output: string | null) {
if (!output) return null
const marker = output.lastIndexOf(" v")
return marker === -1 ? output : output.slice(marker + 2)
return firstLine(result.stdout)
}
export function openWslTerminal(distro?: string | null) {
+4 -53
View File
@@ -13,10 +13,6 @@ import {
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
import {
parseWslOpencodeVersion,
wslOpencodeInstallCommand,
} from "./runtime"
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
@@ -37,51 +33,6 @@ test("rejects an update that did not install the desktop version", () => {
)
})
test("installs the exact V2 CLI package through npm", () => {
const version = "0.0.0-next-17181"
const command = wslOpencodeInstallCommand(version, "beta")
expect(command).toBe("npm install --global --no-audit --no-fund '@opencode-ai/cli@0.0.0-next-17181'")
expect(command).not.toContain("@next")
expect(command).not.toContain("https://opencode.ai/install")
})
test("keeps the curl installer outside the beta channel", () => {
expect(wslOpencodeInstallCommand("1.18.16", "prod")).toBe(
"curl -fsSL https://opencode.ai/install | bash -s -- --version '1.18.16'",
)
expect(wslOpencodeInstallCommand("1.18.16", "dev")).toBe(
"curl -fsSL https://opencode.ai/install | bash -s -- --version '1.18.16'",
)
})
test("reads the version reported by the V2 binary", () => {
expect(parseWslOpencodeVersion("opencode2 v0.0.0-next-17181")).toBe("0.0.0-next-17181")
expect(parseWslOpencodeVersion("1.18.16")).toBe("1.18.16")
})
test("installs the bundled CLI version instead of the Desktop release version", async () => {
persistedServers = []
let requested: { version: string; channel: string; distro: string } | undefined
const controller = createWslServersController(null, async () => new Promise<never>(() => undefined), {
channel: "beta",
readServers: () => persistedServers,
writeServers: () => undefined,
resolveOpencode: async () => "/home/me/.npm/bin/opencode2",
readCommandVersion: async () => "0.0.0-next-17181",
installOpencode: async (version, channel, distro) => {
requested = { version, channel, distro }
return { code: 0, signal: null, stdout: "", stderr: "" }
},
})
controller.setCliVersion("0.0.0-next-17181")
await controller.installOpencode("Debian")
expect(requested).toEqual({ version: "0.0.0-next-17181", channel: "beta", distro: "Debian" })
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
})
test("restarts an existing distro server after updating OpenCode", () => {
expect(
wslServerIdToRestart(
@@ -104,7 +55,7 @@ test("clears cached distro probes when removing a WSL server", () => {
{
Debian: {
distro: "Debian",
resolvedPath: "/home/luke/.local/share/opencode/desktop/beta/1.16.2/opencode2",
resolvedPath: "/home/luke/.opencode/bin/opencode",
version: "1.16.2",
expectedVersion: "1.16.2",
matchesDesktop: true,
@@ -213,7 +164,7 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
},
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
return "/home/me/.opencode/bin/opencode"
},
})
@@ -244,7 +195,7 @@ test("does not check OpenCode in addable distros that cannot execute commands",
}),
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
return "/home/me/.opencode/bin/opencode"
},
})
@@ -274,7 +225,7 @@ function testControllerOptions() {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.local/share/opencode/desktop/dev/1.16.2/opencode2"
return "/home/me/.opencode/bin/opencode"
},
}
}
+8 -25
View File
@@ -37,7 +37,7 @@ type RunningSidecar = {
password: string
}
type SpawnSidecar = (distro: string, version: string, channel: string) => Promise<RunningSidecar>
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
@@ -51,8 +51,6 @@ type WslServersControllerOptions = {
probeDistro?: typeof probeWslDistro
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
installOpencode?: typeof installWslOpencode
channel?: string
}
export type WslServersController = ReturnType<typeof createWslServersController>
@@ -62,7 +60,7 @@ export function wslServerIdForDistro(distro: string) {
}
export function createWslServersController(
initialCliVersion: string | null,
appVersion: string,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
@@ -71,17 +69,10 @@ export function createWslServersController(
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
let cliVersion = initialCliVersion
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const probeDistro = options?.probeDistro ?? probeWslDistro
const channel = options?.channel ?? "dev"
const expectedVersion = () => {
if (cliVersion) return cliVersion
throw new Error(nativeT("desktop.wsl.error.opencodeCannotRun"))
}
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -141,12 +132,11 @@ export function createWslServersController(
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const version = expectedVersion()
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, channel, opts)
const installed = resolved
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, installed, version)
return opencodeCheck(distro, resolved, version, appVersion)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
@@ -239,7 +229,7 @@ export function createWslServersController(
setRuntime(id, { kind: "starting" })
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro, expectedVersion(), channel)
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
@@ -304,10 +294,6 @@ export function createWslServersController(
}
return {
setCliVersion(version: string) {
cliVersion = version
},
getState() {
return state
},
@@ -376,15 +362,12 @@ export function createWslServersController(
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
const version = expectedVersion()
const result = await (options?.installOpencode ?? installWslOpencode)(version, channel, name, {
signal: abort.signal,
})
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode"))
}
await refreshOpencodeCheck(name, { signal: abort.signal })
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, version, name)
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
const id = wslServerIdToRestart(state.servers, name)
if (id) await startServer(id)
})
+2 -14
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"
import { createServer } from "node:net"
import { app } from "electron"
import { checkHealth } from "../server"
import { type WslCommandLine, readWslCommandVersion, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
import { pollWslHealth } from "./startup"
import { nativeT } from "../native-translations"
@@ -16,22 +16,10 @@ export type WslSidecar = {
export async function spawnWslSidecar(
distro: string,
version: string,
channel: string,
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
): Promise<WslSidecar> {
const opencode = await resolveWslOpencode(distro, channel)
const opencode = await resolveWslOpencode(distro)
if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro }))
const installed = await readWslCommandVersion(opencode, distro)
if (installed !== version) {
throw new Error(
nativeT("desktop.wsl.error.updateVersion", {
distro,
installed: installed ?? nativeT("desktop.wsl.error.noVersion"),
expected: version,
}),
)
}
const port = await allocatePort()
const password = randomUUID()
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test"
import { isAllowedCorsOrigin } from "../src/cors"
describe("CORS origin policy", () => {
test("allows the desktop renderer origin", () => {
expect(isAllowedCorsOrigin("oc://renderer")).toBe(true)
})
test("rejects other custom protocol origins", () => {
expect(isAllowedCorsOrigin("other://renderer")).toBe(false)
})
})