Compare commits

...

55 Commits

Author SHA1 Message Date
Sebastian Herrlinger bf61c972a1 fix(tui): preserve current keybind behavior 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger eda6b3eb51 fix(tui): align session tab keybinds 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger d103acd91d test(tui): poll diff viewer readiness 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 1c7be967c0 test(tui): stabilize diff viewer readiness 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger dbf6739f40 test(tui): make diff viewer route reactive 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 55a4cc53f5 fix(cli): make config migration resilient 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger dd2e67d003 test(tui): update new keybind fixture 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 7399dca484 fix(cli): normalize canonical keybind duplicates 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger d7ccc7f9ad test: fix cross-platform keybind fixtures 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger f32029e8b7 test: stabilize keybind integration tests 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 3c2a473470 test(cli): rely on mini input inference 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 068b42576d test: harden keybind integration coverage 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger b021a58b4f test(cli): use owned version metadata 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 6fa47feeda fix(cli): synchronize config migration 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger cde8042178 test(cli): update util imports 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 198c824f68 fix(cli): harden keybind migration 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger ef3037afeb fix(tui): adopt latest keybind defaults 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 86fd3c765e fix(cli): preserve duplicate keybind value 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 65eb47c639 fix(tui): align keybinds with latest v2 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger da04ac39dd test(tui): harden v2 keybind coverage 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger e731f1e8ba fix(tui): complete v2 keybind alignment 2026-08-12 00:33:36 +00:00
Sebastian Herrlinger 8b7d72d046 refactor(tui): use command IDs for v2 keybinds 2026-08-12 00:33:36 +00:00
Luke Parker c217ebe2ad fix(desktop): skip web ui build in dev (#41881) 2026-08-12 10:25:32 +10:00
Dax Raad 4acdeffbe9 refactor(core): require explicit import extensions 2026-08-11 20:22:50 -04:00
Kit Langton dc82a446b9 fix(core): guard Deferred waiter cleanup in vendored effect patch (#41858) 2026-08-11 20:16:21 -04:00
Luke Parker c4bea9d558 fix(app): stabilize markdown worker startup (#41487) 2026-08-12 10:05:32 +10:00
Luke Parker 1190ef3818 feat(desktop): support local server builds (#41486) 2026-08-12 09:56:47 +10:00
opencode-agent[bot] 055a380e66 fix(core): parameterize v1 migration messages (#41877)
Co-authored-by: Dax <826656+thdxr@users.noreply.github.com>
2026-08-11 19:51:37 -04:00
Dax Raad 456c26a012 feat(tui): show localhost on pair screen 2026-08-11 19:49:33 -04:00
opencode-agent[bot] 6072fc5c1b chore: generate 2026-08-11 22:44:47 +00:00
Brendan Allan 20df2fdba6 feat(cli): embed web ui (#41525)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-11 15:43:07 -07:00
opencode-agent[bot] f1399dcb65 fix(desktop): orchestrate update restarts (#41865)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-12 08:38:18 +10:00
Dax Raad 3507bd10b6 fix(www): match OG images to docs theme 2026-08-11 18:28:51 -04:00
Dax Raad 492ae1fdfa feat(www): match legacy docs theme 2026-08-11 18:20:58 -04:00
Kit Langton b00d8d65fe feat(tui): hierarchical slot tree for plugin UI placement (#41189) 2026-08-11 22:00:04 +00:00
Kit Langton f1366d80c8 fix(core): default #sqlite resolution to the node driver (#41834) 2026-08-11 17:50:09 -04:00
opencode-agent[bot] 549a8f826b fix(core): log Plan reminder failures (#41856)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-11 16:39:26 -05:00
Kit Langton 59970699d0 test(core): narrow plugin test layer (#41852) 2026-08-11 17:23:47 -04:00
Aiden Cline b35c5fc985 fix(core): detect copilot PDF input support (#41854) 2026-08-11 16:05:36 -05:00
Kit Langton 627e67323c docs: remove ideal-pseudocode project skill (#41851) 2026-08-11 17:03:05 -04:00
opencode-agent[bot] b97d964e5c chore: generate 2026-08-11 20:53:50 +00:00
Aiden Cline 3fd3e28690 fix(core): classify copilot utility requests (#41840) 2026-08-11 15:51:55 -05:00
Kit Langton b4abeb4788 test(core): narrow effect flock harness (#41837) 2026-08-11 16:51:42 -04:00
Kit Langton 14deb6baf7 docs: clarify skill source precedence (#41844) 2026-08-11 20:46:14 +00:00
Aiden Cline ab6e18e4ff fix(core): route Vertex Anthropic natively (#41836) 2026-08-11 15:29:02 -05:00
Kit Langton 2b6dd2559c fix(core): prefer nearest skill source (#41788) 2026-08-11 16:13:32 -04:00
Kit Langton 2b7bd84867 test(core): speed up subagent tool suite (#41835) 2026-08-11 16:13:04 -04:00
Kit Langton baa54a5150 test(core): speed up shell tool suite (#41831) 2026-08-11 16:03:50 -04:00
Kit Langton c5704d3e3b feat(core): sqlite driver for durable object storage (#41659) 2026-08-11 15:45:23 -04:00
Kit Langton e3336725db fix(core): normalize provider endpoint templates (#41816) 2026-08-11 15:42:54 -04:00
opencode-agent[bot] ff3ecefd24 fix(cli): sign macOS preview binaries (#40662)
Co-authored-by: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com>
2026-08-11 15:37:44 -04:00
Kit Langton 8486bbcdc1 refactor(core): unify fff runtime adapters (#41827) 2026-08-11 15:22:03 -04:00
Kit Langton 2621dde1c9 refactor(core): unify config collection decoding (#41826) 2026-08-11 15:21:52 -04:00
Kit Langton a253c0437e test(core): await shell tool registration (#41825) 2026-08-11 15:21:35 -04:00
opencode-agent[bot] d8b3e528e8 fix(core): use standard web search key dialogs (#41821)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-11 14:13:58 -05:00
401 changed files with 5750 additions and 2482 deletions
+56 -1
View File
@@ -124,12 +124,66 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
name: opencode-preview-cli-unsigned
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
@@ -468,6 +522,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
@@ -1,68 +0,0 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
@@ -3,6 +3,7 @@ import type { UpdaterState } from "@/updater"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
import { formatServerError } from "@/utils/server-errors"
export function updaterAction(state: UpdaterState | undefined) {
if (!state) return { label: "settings.updates.action.checkNow" as const }
@@ -31,7 +32,14 @@ export function useUpdaterAction() {
action,
async run() {
const run = action().run
if (run === "install") return platform.updater?.install()
if (run === "install") {
return platform.updater?.install().catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t, language.t("common.requestFailed")),
})
})
}
if (run !== "check") return
const state = await platform.updater?.check()
+3
View File
@@ -31,6 +31,9 @@ export default [
worker: {
format: "es",
},
optimizeDeps: {
exclude: ["@shikijs/stream", "katex", "marked", "marked-shiki", "remend"],
},
}
},
},
+46
View File
@@ -0,0 +1,46 @@
import { $ } from "bun"
import { readdir } from "node:fs/promises"
import path from "node:path"
import { brotliCompressSync, constants } from "node:zlib"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return compress({})
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
const assets = Object.fromEntries(
await Promise.all(
(await files(path.join(root, "dist")))
.filter((key) => !key.endsWith(".map"))
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
const encoding = isText(key) ? "utf8" : "base64"
return [key, { encoding, content: body.toString(encoding) }] as const
}),
),
)
return compress(assets)
}
function compress(assets: object) {
return brotliCompressSync(JSON.stringify(assets), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).toString("base64")
}
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+10 -1
View File
@@ -12,6 +12,7 @@ import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
import { buildAppArchive } from "./app-assets"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
@@ -55,13 +56,21 @@ const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appArchive = await buildAppArchive(Script.channel)
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
const input = {
version: Script.version,
channel: Script.channel,
models: modelsData,
assetHash,
target,
appArchive,
}
await copyNodeAssets(assets)
await build(mainConfig(input))
+17 -1
View File
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { buildAppArchive } from "./app-assets"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -23,6 +24,7 @@ await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin()
const allTargets: {
@@ -54,6 +56,20 @@ const targets = singleFlag
: allTargets
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi })
const appAssetsPlugin: BunPlugin = {
name: "opencode-app-assets",
setup(build) {
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
path: "opencode-app-assets",
namespace: "opencode",
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `export default ${JSON.stringify(appArchive)}`,
}))
},
}
for (const item of targets) {
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
@@ -80,7 +96,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin, parcelWatcherPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+51
View File
@@ -0,0 +1,51 @@
import { Effect, FileSystem, Option } from "effect"
import path from "node:path"
import { brotliDecompressSync } from "node:zlib"
import { OPENCODE_LOCAL } from "./version"
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
export const load = Effect.fn("cli.app-assets.load")(function* () {
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
return decode(yield* sourceAssets())
})
function decodeArchive(archive: string) {
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
return decode(JSON.parse(body) as EncodedAssetMap)
}
const sourceAssets = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const root = path.resolve(import.meta.dirname, "../../app/dist")
const files = yield* fs.readDirectory(root, { recursive: true })
return Object.fromEntries(
(yield* Effect.forEach(
files.filter((file) => !file.endsWith(".map")),
Effect.fnUntraced(function* (file) {
const target = path.join(root, file)
if ((yield* fs.stat(target)).type === "Directory") return
const body = Buffer.from(yield* fs.readFile(target))
const encoding = isText(file) ? "utf8" : "base64"
return [file, { encoding, content: body.toString(encoding) }] as const
}),
{ concurrency: "unbounded" },
)).filter((asset) => asset !== undefined),
)
})
function decode(assets: EncodedAssetMap): AssetMap {
return Object.fromEntries(
Object.entries(assets).map(([key, asset]) => [
key,
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
]),
)
}
function isText(file: string) {
return file === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(file)
}
+1 -1
View File
@@ -267,7 +267,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("serve", {
description: "Start the v2 API server",
description: "Start the v2 API and web server",
params: {
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
+53 -31
View File
@@ -1,7 +1,8 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/util/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { Flock } from "@opencode-ai/util/flock"
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
@@ -28,7 +29,6 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -49,38 +49,60 @@ export const layer = Layer.effect(
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
const withLock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.scoped(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const lock = yield* restore(
Effect.promise((signal) => Flock.acquire(file, { dir: path.join(global.state, "locks"), signal })),
)
yield* Effect.addFinalizer(() => Effect.promise(() => lock.release()))
return yield* restore(effect)
}),
),
)
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
Effect.gen(function* () {
const migration = yield* migrate.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to migrate cli config", { cause }).pipe(Effect.as(undefined)),
),
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
lock
.withPermits(1)(
Effect.gen(function* () {
yield* migrate
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
)
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
withLock(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
return Service.of({ path: file, get, update })
+123 -15
View File
@@ -1,13 +1,18 @@
export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import { randomUUID } from "crypto"
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import type { Info } from "./schema"
import { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const LegacyKeybindTargets = new Set<string>(Object.values(TuiKeybind.CommandMap))
export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string
@@ -15,7 +20,60 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const persist = Effect.fnUntraced(function* (text: string, info: Info) {
const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
const cause = yield* Effect.gen(function* () {
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, input.file)
}).pipe(
Effect.as(undefined),
Effect.catchCause((cause) => Effect.succeed(cause)),
Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
)
return cause === undefined ? { info } : { info, cause }
})
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
const text = yield* fs.readFileString(input.file)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
const config = Option.getOrUndefined(decodeRecord(value))
if (config === undefined) return
const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
if (keybinds === undefined) return
const deduped = findKeybindObjects(text)
.slice(0, -1)
.reduce((text) => {
const property = findKeybindObjects(text)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
const updated = Object.keys(keybinds).reduce((text, name) => {
const target =
TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
(name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined)
if (target === undefined) return text
const properties = findKeybindProperties(text, name)
if (!properties.length) return text
const remove = !(target in Definitions) || (target !== name && target in keybinds)
// The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
const property = findKeybindProperties(text, name)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
if (remove) return updated
if (target === name) return updated
const key = findKeybindProperties(updated, name)[0]?.children?.[0]
if (key === undefined) return text
return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
}, deduped)
if (updated === text) return
const updatedErrors: ParseError[] = []
const info = Option.getOrUndefined(decodeInfo(parse(updated, updatedErrors, { allowTrailingComma: true })))
if (updatedErrors.length || info === undefined) return
return yield* persist(updated, info)
}
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
@@ -23,19 +81,59 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const temp = input.file + ".tmp"
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, input.file)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
return result
})
function findKeybindProperties(text: string, name: string) {
const keybinds = findKeybindObjects(text).at(-1)?.children?.[1]
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
}
function findKeybindObjects(text: string) {
const tree = parseTree(text)
if (tree === undefined) return []
return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? []
}
function removeProperty(text: string, property: Node) {
const properties = property.parent?.children ?? []
const index = properties.indexOf(property)
const end = property.offset + property.length
const next = properties[index + 1]
if (next) {
const comma = findComma(text, end, next.offset)
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
}
const previous = properties[index - 1]
if (previous) {
const comma = findComma(text, previous.offset + previous.length, property.offset)
if (comma !== undefined) return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
}
const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
return text.slice(0, property.offset) + text.slice(end)
}
function findComma(text: string, start: number, end: number) {
const scanner = createScanner(text, false)
scanner.setPosition(start)
while (true) {
scanner.scan()
const offset = scanner.getTokenOffset()
if (scanner.getTokenLength() === 0 || offset >= end) return
if (text[offset] === ",") return offset
}
}
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
@@ -49,6 +147,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking =
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
const keybinds =
legacy?.keybinds === undefined
? undefined
: Object.fromEntries(
Object.entries(legacy.keybinds).flatMap(([name, value]) => {
const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
if (!(target in Definitions)) return []
return [[target, value]]
}),
)
return {
...(themeName !== undefined || themeMode !== undefined
@@ -59,7 +167,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
},
}
: {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(keybinds === undefined ? {} : { keybinds }),
...(plugins.length ? { plugins } : {}),
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
+10 -7
View File
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -43,16 +44,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
if (
serviceOptions !== undefined &&
port !== undefined &&
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
)
return
const incumbent =
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -68,6 +69,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
: randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID()
const transform = yield* WebUi.handler()
const server = yield* start(
{
app: {
@@ -122,6 +124,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
transform,
).pipe(
Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
@@ -143,7 +146,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (server === undefined) return
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* options.mode === "service"
+63
View File
@@ -0,0 +1,63 @@
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, FileSystem } from "effect"
import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createHash } from "node:crypto"
import { load, type AssetMap } from "../app-assets"
export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { readonly assets?: AssetMap }) {
const fileSystem = yield* FileSystem.FileSystem
const assets = options?.assets
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
if (request.method !== "GET" && request.method !== "HEAD")
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
const html = name === "index.html"
const headers = {
"content-type": FSUtil.mimeType(name),
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
"content-security-policy": html
? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString())
: csp(),
"x-content-type-options": "nosniff",
}
return Effect.succeed(
request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }),
)
}
function isRouteNotFound(error: unknown) {
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
}
function csp(hash = "") {
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
}
function cspForHtml(body: string) {
const match = body.match(
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
)
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
}
export * as WebUi from "./web-ui"
+4
View File
@@ -0,0 +1,4 @@
declare module "virtual:opencode-app-assets" {
const archive: string
export default archive
}
+288 -4
View File
@@ -1,7 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { Effect, FileSystem, Option } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
import { Config } from "../src/config"
@@ -21,7 +23,14 @@ test("migrates tui and kv config into cli.json", async () => {
path.join(directory, "tui.json"),
JSON.stringify({
theme: "legacy",
keybinds: { leader: "ctrl+o" },
keybinds: {
leader: "ctrl+o",
app_exit: "ctrl+q",
app_heap_snapshot: "ctrl+h",
input_paste: { key: "ctrl+v", preventDefault: false },
session_delete: false,
"dialog.select.next": "ctrl+n",
},
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
@@ -65,7 +74,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).toMatchObject({
theme: { name: "legacy", mode: "light" },
keybinds: { leader: "ctrl+o" },
keybinds: {
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
},
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
@@ -80,7 +95,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
})
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
@@ -141,6 +162,257 @@ test("preserves legacy cursor settings", async () => {
}
})
test("migrates legacy keybind names in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
// Preserve this comment
"keybinds": {
// Session list shortcut
"session_list": "ctrl+l",
"app_heap_snapshot": "ctrl+h",
// Legacy delete shortcut
"session_delete": "ctrl+d",
// Canonical delete shortcut
"session.delete": "ctrl+x",
"app.heap_snapshot": "ctrl+shift+h"
}
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
const text = await Bun.file(file).text()
expect(text).toContain("// Preserve this comment")
expect(text).toContain("// Session list shortcut")
expect(text).toContain("// Legacy delete shortcut")
expect(text).toContain("// Canonical delete shortcut")
expect(parse(text).keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("uses migrated keybinds when persistence fails", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "rename") return () => Effect.die(new Error("read-only config"))
return Reflect.get(target, property, receiver)
},
})
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates and updates the effective duplicate top-level keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("serializes migration and updates across processes", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const migrateReady = path.join(directory, "migrate-ready")
const updateReady = path.join(directory, "update-ready")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(started, migrate.exited)
const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("")
expect(await new Response(update.stderr).text()).toBe("")
expect([migrateCode, updateCode]).toEqual([0, 0])
expect(await Bun.file(file).json()).toEqual({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
update.kill()
await update.exited
}
} finally {
await Bun.write(release, "")
migrate.kill()
await migrate.exited
await Bun.$`rm -rf ${directory}`
}
})
test("config reads remain interruptible while waiting for the file lock", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const locks = path.join(directory, "locks")
const held = await Flock.acquire(file, { dir: locks })
try {
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
const result = Effect.runPromise(service.get().pipe(Effect.timeoutOption("50 millis")))
expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
} finally {
await held.release()
await Bun.$`rm -rf ${directory}`
}
})
test("updates effective duplicate canonical keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
"session.delete": "changed",
"permission.mode": "changed",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("removes orphaned keybinds without deleting trailing comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
"keybinds": {
"app_heap_snapshot": "ctrl+h" /* Keep legacy explanation */,
"app.heap_snapshot": "ctrl+shift+h" /* Keep canonical explanation */,
},
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({})
const text = await Bun.file(file).text()
expect(text).toContain("/* Keep legacy explanation */")
expect(text).toContain("/* Keep canonical explanation */")
expect(parse(text).keybinds).toEqual({})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
@@ -167,3 +439,15 @@ test("updates a config draft while preserving JSONC comments", async () => {
await Bun.$`rm -rf ${directory}`
}
})
async function waitForFile(file: string, exited: Promise<number>) {
const found = await Promise.race([
(async () => {
while (!(await Bun.file(file).exists())) await Bun.sleep(10)
return true
})(),
exited.then(() => false),
Bun.sleep(5000).then(() => false),
])
if (!found) throw new Error(`timed out waiting for ${file}`)
}
@@ -0,0 +1,42 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Config } from "../../src/config"
const [mode, directory, started, release, ready] = process.argv.slice(2)
if (!mode || !directory || !started || !release || !ready) throw new Error("missing config concurrency arguments")
if (mode !== "migrate" && mode !== "update") throw new Error(`unknown mode: ${mode}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const state = { writes: 0 }
const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
state.writes++
if (mode !== "migrate" || state.writes !== 1) return node.writeFileString(target, data, options)
return Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
while (!(yield* Effect.promise(() => Bun.file(release).exists()))) yield* Effect.sleep("10 millis")
yield* node.writeFileString(target, data, options)
})
}
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "writeFileString") return writeFileString
return Reflect.get(target, property, receiver)
},
})
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
await Bun.write(ready, "")
if (mode === "migrate") await Effect.runPromise(service.get())
if (mode === "update")
await Effect.runPromise(
service.update((draft) => {
draft.mouse = false
}),
)
+69
View File
@@ -0,0 +1,69 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, Option } from "effect"
import { expect, mock, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { Config } from "../src/config"
import type { MiniCommandInput } from "../src/mini"
import { OPENCODE_VERSION } from "../src/version"
test("mini handler passes resolved CLI keybinds to the runtime", async () => {
const root = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const configDirectory = path.join(root, "config")
const stateDirectory = path.join(root, "state")
await mkdir(configDirectory, { recursive: true })
await Bun.write(
path.join(configDirectory, "cli.json"),
JSON.stringify({
keybinds: { "composer.subagent.interrupt": "ctrl+i" },
leader: { timeout: 321 },
}),
)
let received: MiniCommandInput["tuiConfig"]
const mini = await import("../src/mini")
mock.module("../src/mini", () => ({
...mini,
validateMiniTerminal() {},
runMini(input: Pick<MiniCommandInput, "tuiConfig">) {
received = input.tuiConfig
return Promise.resolve()
},
}))
const handler = (await import("../src/commands/handlers/mini")).default
const server = Bun.serve({
port: 0,
fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
})
try {
await Effect.runPromise(
handler({
server: Option.some(server.url.toString()),
standalone: false,
continue: false,
session: Option.none(),
fork: false,
replay: true as never,
replayLimit: Option.none(),
model: Option.none(),
agent: Option.none(),
prompt: Option.none(),
demo: false,
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
)
const config = await received
expect(config?.leader.timeout).toBe(321)
expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
} finally {
server.stop(true)
mock.restore()
await rm(root, { recursive: true, force: true })
}
})
+70
View File
@@ -0,0 +1,70 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
await writeFile(index, "<html><body>embedded</body></html>")
await writeFile(asset, "console.log('embedded')")
const assets = {
"index.html": await Bun.file(index).text(),
"app.js": await Bun.file(asset).text(),
"font.woff2": new Uint8Array([0, 1, 2, 255]),
}
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transform = yield* WebUi.handler({ assets })
const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
yield* http.serve(
transform(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
}),
)
}),
),
)
const origin = HttpServer.formatAddress(http.address)
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
expect(yield* Effect.promise(() => missing.text())).toBe("")
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
new Uint8Array([0, 1, 2, 255]),
)
const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
}),
).pipe(Effect.provide(NodeFileSystem.layer)),
)
})
})
+16
View File
@@ -17,6 +17,19 @@ function rawTextPlugin(): Plugin {
}
}
function appAssetsPlugin(archive: string): Plugin {
return {
name: "opencode:app-assets",
resolveId(id) {
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `export default ${JSON.stringify(archive)}`
},
}
}
function runtimeRequirePlugin(): Plugin {
return {
name: "opencode:runtime-require",
@@ -212,12 +225,14 @@ export type NodeBuildInput = {
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
readonly appArchive: string
}
export function mainConfig(input: NodeBuildInput): UserConfig {
return defineConfig({
root: dir,
plugins: [
appAssetsPlugin(input.appArchive),
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
@@ -259,4 +274,5 @@ export default mainConfig({
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "",
})
@@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyCreateOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
@@ -1753,7 +1753,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyRemoveOutput>(
{
method: "DELETE",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
@@ -1766,7 +1766,7 @@ export function make(options: ClientOptions) {
request<ProjectCopyRefreshOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
+2 -2
View File
@@ -9310,7 +9310,7 @@
"summary": "List references"
}
},
"/experimental/project/{projectID}/copy": {
"/api/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
@@ -9536,7 +9536,7 @@
}
}
},
"/experimental/project/{projectID}/copy/refresh": {
"/api/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
+2 -1
View File
@@ -25,9 +25,10 @@
},
"imports": {
"#sqlite": {
"workerd": "./src/database/sqlite.workerd.ts",
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts"
"default": "./src/database/sqlite.node.ts"
},
"#pty": {
"bun": "./src/pty/pty.bun.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import { Timestamps } from "../database/schema.sql.js"
export const AccountTable = sqliteTable("account", {
id: text().primaryKey(),
+3 -3
View File
@@ -1,12 +1,12 @@
export * as Agent from "./agent"
export * as Agent from "./agent.js"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Global } from "@opencode-ai/util/global"
import { Bus } from "./bus"
import { State } from "./state"
import { Bus } from "./bus.js"
import { State } from "./state.js"
const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")
+38 -10
View File
@@ -1,7 +1,7 @@
export * as AISDKNative from "./aisdk-native"
export * as AISDKNative from "./aisdk-native.js"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Provider } from "./provider"
import { Provider } from "./provider.js"
export interface Mapping {
readonly package: string
@@ -51,6 +51,27 @@ export function map(input: MapInput): Mapping | undefined {
...mapGoogleOptions(input.settings),
},
}
case "@ai-sdk/google-vertex/anthropic":
return {
package: "@opencode-ai/ai/providers/google-vertex/messages",
settings: {
...baseSettings,
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
? {
providerOptions: {
anthropic: {
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
},
},
}
: {}),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(input.settings, baseSettings)
case "@ai-sdk/xai":
@@ -88,9 +109,13 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const credentials = mapBedrockCredentials(settings)
const region = bedrockRegion(settings)
const credentials = mapBedrockCredentials(settings, region)
return {
...baseSettings,
...(typeof baseSettings.baseURL === "string" && region !== undefined
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
: {}),
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
@@ -155,14 +180,8 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
}
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
const region =
typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
if (
region === undefined ||
typeof credentials.accessKeyId !== "string" ||
@@ -177,6 +196,15 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
}
}
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
return typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
+4 -4
View File
@@ -1,4 +1,4 @@
export * as AISDK from "./aisdk"
export * as AISDK from "./aisdk.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { APICallError } from "@ai-sdk/provider"
@@ -34,9 +34,9 @@ import {
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
import { Provider } from "./provider"
import { State } from "./state"
import type { ID, Info } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
type SDK = any
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
+1 -1
View File
@@ -1,4 +1,4 @@
export * as App from "./app"
export * as App from "./app.js"
import { Context, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
+4 -4
View File
@@ -1,12 +1,12 @@
export * as Bus from "./bus"
export * as Bus from "./bus.js"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { Database } from "./database/database.js"
import { EventSequenceTable, EventTable } from "./event/sql.js"
import { Location } from "./location.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
+6 -6
View File
@@ -1,13 +1,13 @@
export * as Catalog from "./catalog"
export * as Catalog from "./catalog.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { Model } from "./model"
import { Provider } from "./provider"
import { Bus } from "./bus"
import { State } from "./state"
import { Integration } from "./integration"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { Integration } from "./integration.js"
export type ProviderRecord = {
provider: Provider.MutableInfo
+1 -1
View File
@@ -1,4 +1,4 @@
export * as CodeModeCatalog from "./catalog"
export * as CodeModeCatalog from "./catalog.js"
import { Schema } from "effect"
+3 -3
View File
@@ -1,9 +1,9 @@
export * as CodeModeInstructions from "./instructions"
export * as CodeModeInstructions from "./instructions.js"
import { searchSignature, toolExpression } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
import { Instructions } from "../instructions/index.js"
import { CodeModeCatalog } from "./catalog.js"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
+2 -2
View File
@@ -1,9 +1,9 @@
export * as CodeModeTool from "./tool"
export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition } from "../tool/runtime"
import { definition } from "../tool/runtime.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
+7 -7
View File
@@ -1,16 +1,16 @@
export * as Command from "./command"
export * as Command from "./command.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
import { MCP } from "./mcp/index"
import { Bus } from "./bus"
import { State } from "./state.js"
import { MCP } from "./mcp/index.js"
import { Bus } from "./bus.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config"
import { Location } from "./location"
import { ShellSelect } from "./shell/select"
import { Config } from "./config.js"
import { Location } from "./location.js"
import { ShellSelect } from "./shell/select.js"
import { Global } from "@opencode-ai/util/global"
export const Info = Command.Info
+11 -11
View File
@@ -1,4 +1,4 @@
export * as Config from "./config"
export * as Config from "./config.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
@@ -16,16 +16,16 @@ import {
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { Bus } from "./bus"
import { Watcher } from "./filesystem/watcher"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
import { Watcher } from "./filesystem/watcher.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigVariable } from "./config/variable"
import { ConfigNormalize } from "./config/normalize"
import { WellKnown } from "./wellknown"
import { Location } from "./location.js"
import { AbsolutePath } from "./schema.js"
import { ConfigVariable } from "./config/variable.js"
import { ConfigNormalize } from "./config/normalize.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries
@@ -204,13 +204,13 @@ export const layer = (options?: Options) =>
const claude = [
...new Set([
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".claude"),
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...discovered.filter((item) => path.basename(item) === ".agents"),
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
+1 -1
View File
@@ -1,4 +1,4 @@
export * as ConfigMarkdown from "./markdown"
export * as ConfigMarkdown from "./markdown.js"
import matter from "gray-matter"
export function parse(content: string) {
+58 -79
View File
@@ -1,4 +1,4 @@
export * as ConfigNormalize from "./normalize"
export * as ConfigNormalize from "./normalize.js"
import { isDeepStrictEqual } from "node:util"
import { Option, Schema } from "effect"
@@ -16,15 +16,15 @@ import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigAgentV1 } from "../v1/config/agent"
import { ConfigAttachmentV1 } from "../v1/config/attachment"
import { ConfigCommandV1 } from "../v1/config/command"
import { ConfigMCPV1 } from "../v1/config/mcp"
import { ConfigPermissionV1 } from "../v1/config/permission"
import { ConfigPluginV1 } from "../v1/config/plugin"
import { ConfigProviderV1 } from "../v1/config/provider"
import { ConfigMigrateV1 } from "../v1/config/migrate"
import { PositiveInt } from "../schema"
import { ConfigAgentV1 } from "../v1/config/agent.js"
import { ConfigAttachmentV1 } from "../v1/config/attachment.js"
import { ConfigCommandV1 } from "../v1/config/command.js"
import { ConfigMCPV1 } from "../v1/config/mcp.js"
import { ConfigPermissionV1 } from "../v1/config/permission.js"
import { ConfigPluginV1 } from "../v1/config/plugin.js"
import { ConfigProviderV1 } from "../v1/config/provider.js"
import { ConfigMigrateV1 } from "../v1/config/migrate.js"
import { PositiveInt } from "../schema.js"
export interface Diagnostic {
readonly kind: "conflict" | "invalid" | "unsupported"
@@ -83,8 +83,14 @@ export function normalize(input: unknown): Result {
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
const nativeReferences = decodeMap(
input.references,
ConfigReference.Entry,
["references"],
diagnostics,
decodeEncoded,
)
mergeMap(
encoded,
"references",
@@ -94,13 +100,13 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
mergeMap(
encoded,
"commands",
@@ -110,8 +116,9 @@ export function normalize(input: unknown): Result {
diagnostics,
)
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
const legacyAgents = mapValues(
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
@@ -130,11 +137,11 @@ export function normalize(input: unknown): Result {
model: migratedSmallModel,
...legacyAgents.title,
}
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
mergeMap(
@@ -147,7 +154,7 @@ export function normalize(input: unknown): Result {
)
const legacyProviders = migrateProviders(input.provider, diagnostics)
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
mergeMap(
encoded,
"providers",
@@ -159,14 +166,14 @@ export function normalize(input: unknown): Result {
const toolRules = migrateTools(input.tools, diagnostics)
const permissionRules = migratePermissions(input.permission, diagnostics)
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
)
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
encoded.plugins = [...legacyPlugins, ...nativePlugins]
@@ -200,7 +207,7 @@ export function normalize(input: unknown): Result {
overlay(encoded, key, value, [key], diagnostics)
})
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
return { type: "normalized", encoded, diagnostics }
@@ -209,7 +216,7 @@ export function normalize(input: unknown): Result {
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "skills")) return
if (Array.isArray(input.skills)) {
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
encoded.skills = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
return
}
if (!isRecord(input.skills)) {
@@ -217,8 +224,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
return
}
encoded.skills = [
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
]
}
@@ -248,8 +255,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
return
}
if (name === "servers" && !isDirectLegacyMcp(value)) {
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
setOwn(nativeServers, key, server),
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
([key, server]) => setOwn(nativeServers, key, server),
)
return
}
@@ -404,7 +411,13 @@ function normalizeExperimental(
if (value !== undefined) result.subagent_depth = value
}
native.push(
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
...decodeList(
experimental.policies,
ConfigPolicy.Info,
["experimental", "policies"],
diagnostics,
decodeEncoded,
),
)
}
}
@@ -420,7 +433,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
invalid(["watcher"], diagnostics)
return
}
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
}
@@ -435,7 +448,7 @@ function normalizeFormatter(
if (value !== undefined) encoded.formatter = value
return
}
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
encoded.formatter = entries
}
@@ -447,7 +460,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
if (value !== undefined) encoded.lsp = value
return
}
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
}
@@ -597,78 +610,44 @@ function decodeProviderList(
return {
present: true,
nonEmpty: input[key].length > 0,
values: decodeList(input[key], Schema.String, [key], diagnostics),
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
}
}
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): Record<string, A> {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(path, diagnostics)
return {}
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
Object.entries(value).flatMap(([name, raw]): [string, A][] => {
const decoded = decode(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
)
}
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return {} as Record<string, S["Type"]>
if (!isRecord(value)) {
invalid(path, diagnostics)
return {} as Record<string, S["Type"]>
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
) as Record<string, S["Type"]>
}
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Encoded"][]
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
): A[] {
if (value === undefined) return []
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Encoded"][]
return []
}
return value.flatMap((item, index) => {
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Type"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Type"][]
}
return value.flatMap((item, index) => {
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
+10 -10
View File
@@ -1,21 +1,21 @@
export * as ConfigAgentPlugin from "./agent"
export * as ConfigAgentPlugin from "./agent.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Agent } from "../../agent"
import { Config } from "../../config"
import { ConfigMarkdown } from "../markdown"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { ConfigMarkdown } from "../markdown.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { ConfigAgentV1 } from "../../v1/config/agent.js"
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "../../permission"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/plugin/read"
import type { EditTool } from "../../tool/plugin/edit"
import { Permission } from "../../permission.js"
import type { LocationMutation } from "../../location-mutation.js"
import type { ReadTool } from "../../tool/plugin/read.js"
import type { EditTool } from "../../tool/plugin/edit.js"
const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
+4 -4
View File
@@ -1,14 +1,14 @@
export * as ConfigCommandPlugin from "./command"
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Command } from "../../command"
import { Config } from "../../config"
import { Command } from "../../command.js"
import { Config } from "../../config.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigMarkdown } from "../markdown"
import { ConfigMarkdown } from "../markdown.js"
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
@@ -1,15 +1,15 @@
export * as ConfigInstructionPlugin from "./instruction"
export * as ConfigInstructionPlugin from "./instruction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import { Watcher } from "../../filesystem/watcher"
import { InstructionDiscovery } from "../../instruction-discovery"
import { Instructions } from "../../instructions/index"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
import { Watcher } from "../../filesystem/watcher.js"
import { InstructionDiscovery } from "../../instruction-discovery.js"
import { Instructions } from "../../instructions/index.js"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
type Loaded =
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
+3 -3
View File
@@ -1,10 +1,10 @@
export * as ConfigPolicyPlugin from "./policy"
export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Wildcard } from "../../util/wildcard"
import { Config } from "../../config.js"
import { Wildcard } from "../../util/wildcard.js"
export const Plugin = define({
id: "opencode.config.policy",
+3 -3
View File
@@ -1,11 +1,11 @@
export * as ConfigProviderPlugin from "./provider"
export * as ConfigProviderPlugin from "./provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Provider } from "../../provider"
import { Config } from "../../config.js"
import { Provider } from "../../provider.js"
export const Plugin = define({
id: "opencode.config.provider",
+5 -5
View File
@@ -1,15 +1,15 @@
export * as ConfigReferencePlugin from "./reference"
export * as ConfigReferencePlugin from "./reference.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import path from "path"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
import { Config } from "../../config.js"
import { Reference } from "../../reference.js"
import { AbsolutePath } from "../../schema.js"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location"
import { Location } from "../../location.js"
export const Plugin = define({
id: "opencode.config.reference",
@@ -1,10 +1,10 @@
export * as SkillFile from "./skill-file"
export * as SkillFile from "./skill-file.js"
import path from "path"
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
import { ConfigMarkdown } from "../markdown"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
import { ConfigMarkdown } from "../markdown.js"
import { AbsolutePath } from "../../schema.js"
import { Skill } from "../../skill.js"
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
+8 -8
View File
@@ -1,4 +1,4 @@
export * as ConfigSkillPlugin from "./skill"
export * as ConfigSkillPlugin from "./skill.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import type { Entry } from "@opencode-ai/schema/config"
@@ -6,13 +6,13 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import path from "path"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
import { SkillDiscovery } from "../../skill/discovery"
import { SkillFile } from "./skill-file"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
import { Skill } from "../../skill.js"
import { SkillDiscovery } from "../../skill/discovery.js"
import { SkillFile } from "./skill-file.js"
type Source = Skill.DirectorySource | Skill.UrlSource
+4 -4
View File
@@ -1,4 +1,4 @@
export * as ConfigPluginSource from "./source"
export * as ConfigPluginSource from "./source.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
@@ -7,9 +7,9 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config"
import { Watcher } from "../../filesystem/watcher"
import { Location } from "../../location"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
export type Operation =
| {
+2 -2
View File
@@ -1,8 +1,8 @@
export * as ConfigWebSearchPlugin from "./websearch"
export * as ConfigWebSearchPlugin from "./websearch.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Config } from "../../config.js"
export const Plugin = define({
id: "opencode.config.websearch",
+2 -2
View File
@@ -1,10 +1,10 @@
export * as ConfigVariable from "./variable"
export * as ConfigVariable from "./variable.js"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { InvalidError } from "../v1/config/error"
import { InvalidError } from "../v1/config/error.js"
type ParseSource =
| {
+3 -3
View File
@@ -1,12 +1,12 @@
export * as Credential from "./credential"
export * as Credential from "./credential.js"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
import { Database } from "./database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql"
import { CredentialTable } from "./credential/sql.js"
export const ID = Credential.ID
export type ID = Credential.ID
+2 -2
View File
@@ -1,6 +1,6 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { Credential } from "../credential"
import { Timestamps } from "../database/schema.sql.js"
import type { Credential } from "../credential.js"
export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(),
+20 -10
View File
@@ -1,11 +1,12 @@
export * as Database from "./database"
export * as Database from "./database.js"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { sqliteLayer } from "#sqlite"
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { DatabaseMigration } from "./migration.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
@@ -27,12 +28,15 @@ const databaseLayer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
yield* DatabaseMigration.apply(db)
return { db }
@@ -42,7 +46,7 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -51,6 +55,12 @@ export function layer(options: Options = { path: ":memory:" }) {
)
}
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
// because migrations may read it (the v1 import).
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
+43 -43
View File
@@ -1,46 +1,46 @@
import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
import m41 from "./migration/20260811161259_execution_claim_attempts"
import type { DatabaseMigration } from "./migration.js"
import m00 from "./migration/20260127222353_familiar_lady_ursula.js"
import m01 from "./migration/20260211171708_add_project_commands.js"
import m02 from "./migration/20260213144116_wakeful_the_professor.js"
import m03 from "./migration/20260225215848_workspace.js"
import m04 from "./migration/20260227213759_add_session_workspace_id.js"
import m05 from "./migration/20260228203230_blue_harpoon.js"
import m06 from "./migration/20260303231226_add_workspace_fields.js"
import m07 from "./migration/20260309230000_move_org_to_state.js"
import m08 from "./migration/20260312043431_session_message_cursor.js"
import m09 from "./migration/20260323234822_events.js"
import m10 from "./migration/20260410174513_workspace-name.js"
import m11 from "./migration/20260413175956_chief_energizer.js"
import m12 from "./migration/20260423070820_add_icon_url_override.js"
import m13 from "./migration/20260427172553_slow_nightmare.js"
import m14 from "./migration/20260428004200_add_session_path.js"
import m15 from "./migration/20260501142318_next_venus.js"
import m16 from "./migration/20260504145000_add_sync_owner.js"
import m17 from "./migration/20260507164347_add_workspace_time.js"
import m18 from "./migration/20260510033149_session_usage.js"
import m19 from "./migration/20260511000411_data_migration_state.js"
import m20 from "./migration/20260511173437_session-metadata.js"
import m21 from "./migration/20260601010001_normalize_storage_paths.js"
import m22 from "./migration/20260601202201_amazing_prowler.js"
import m23 from "./migration/20260602002951_lowly_union_jack.js"
import m24 from "./migration/20260602182828_add_project_directories.js"
import m25 from "./migration/20260603001617_session_message_projection_indexes.js"
import m26 from "./migration/20260603040000_session_message_projection_order.js"
import m27 from "./migration/20260603141458_session_input_inbox.js"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane.js"
import m29 from "./migration/20260604172448_event_sourced_session_input.js"
import m30 from "./migration/20260605003541_add_session_context_snapshot.js"
import m31 from "./migration/20260605042240_add_context_epoch_agent.js"
import m32 from "./migration/20260611035744_credential.js"
import m33 from "./migration/20260611192811_lush_chimera.js"
import m34 from "./migration/20260612174303_project_dir_strategy.js"
import m35 from "./migration/20260622142730_simplify_session_context_epoch.js"
import m36 from "./migration/20260622170816_reset_v2_session_state.js"
import m37 from "./migration/20260622202450_simplify_session_input.js"
import m38 from "./migration/20260804233008_loose_psylocke.js"
import m39 from "./migration/20260805200742_import_legacy_credentials.js"
import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
export const migrations = [
m00,
+15 -6
View File
@@ -1,10 +1,11 @@
export * as DatabaseMigration from "./migration"
export * as DatabaseMigration from "./migration.js"
import { sql } from "drizzle-orm"
import { Effect, Semaphore } from "effect"
import { supportsForeignKeyToggle } from "#sqlite"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
import schema from "./schema.gen"
import { migrations } from "./migration.gen.js"
import schema from "./schema.gen.js"
import { Global } from "@opencode-ai/util/global"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
@@ -20,8 +21,10 @@ export type Migration = {
export function apply(db: Database) {
return lock.withPermit(
Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
@@ -103,9 +106,15 @@ export function applyOnly(db: Database, input: Migration[]) {
})
continue
}
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
// Durable Object SQLite rejects the foreign_keys toggle; the closest
// allowlisted relaxation is deferring enforcement to transaction commit.
const relaxForeignKeys = supportsForeignKeyToggle
? db.run(sql`PRAGMA foreign_keys = OFF`)
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
yield* relaxForeignKeys
yield* apply.pipe(
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260127222353_familiar_lady_ursula",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260211171708_add_project_commands",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260213144116_wakeful_the_professor",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260225215848_workspace",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260227213759_add_session_workspace_id",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260228203230_blue_harpoon",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260303231226_add_workspace_fields",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260309230000_move_org_to_state",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260312043431_session_message_cursor",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260323234822_events",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260410174513_workspace-name",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260413175956_chief_energizer",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260423070820_add_icon_url_override",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260427172553_slow_nightmare",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260428004200_add_session_path",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260501142318_next_venus",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260504145000_add_sync_owner",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260507164347_add_workspace_time",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260510033149_session_usage",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260511000411_data_migration_state",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260511173437_session-metadata",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260601010001_normalize_storage_paths",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260601202201_amazing_prowler",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260602002951_lowly_union_jack",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260602182828_add_project_directories",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260603001617_session_message_projection_indexes",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260603040000_session_message_projection_order",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260603141458_session_input_inbox",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260603160727_jittery_ezekiel_stane",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260604172448_event_sourced_session_input",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260605003541_add_session_context_snapshot",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260605042240_add_context_epoch_agent",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260611035744_credential",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260611192811_lush_chimera",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260612174303_project_dir_strategy",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260622142730_simplify_session_context_epoch",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260622170816_reset_v2_session_state",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260622202450_simplify_session_input",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
@@ -6,7 +6,7 @@ import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { NonNegativeInt } from "@opencode-ai/schema/schema"
import { Global } from "@opencode-ai/util/global"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const LegacyOAuth = Schema.Struct({
type: Schema.Literal("oauth"),
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260808023530_workspace_domain",
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260811161259_execution_claim_attempts",
+1 -1
View File
@@ -1,7 +1,7 @@
import nodePath from "path"
import { customType } from "drizzle-orm/sqlite-core"
import { Schema } from "effect"
import { AbsolutePath } from "../schema"
import { AbsolutePath } from "../schema.js"
function storagePath(input: string) {
if (process.platform !== "win32") return input
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "./migration"
import type { DatabaseMigration } from "./migration.js"
const schema: Omit<DatabaseMigration.Migration, "id"> = {
up(tx) {
+6 -1
View File
@@ -4,10 +4,15 @@ import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
import { Sqlite } from "./sqlite.js"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
+6 -1
View File
@@ -4,10 +4,15 @@ import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
import { Sqlite } from "./sqlite.js"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
+1 -1
View File
@@ -1,4 +1,4 @@
export * as Sqlite from "./sqlite"
export * as Sqlite from "./sqlite.js"
import { Context, Effect, Fiber, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"

Some files were not shown because too many files have changed in this diff Show More