mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf61c972a1 | |||
| eda6b3eb51 | |||
| d103acd91d | |||
| 1c7be967c0 | |||
| dbf6739f40 | |||
| 55a4cc53f5 | |||
| dd2e67d003 | |||
| 7399dca484 | |||
| d7ccc7f9ad | |||
| f32029e8b7 | |||
| 3c2a473470 | |||
| 068b42576d | |||
| b021a58b4f | |||
| 6fa47feeda | |||
| cde8042178 | |||
| 198c824f68 | |||
| ef3037afeb | |||
| 86fd3c765e | |||
| 65eb47c639 | |||
| da04ac39dd | |||
| e731f1e8ba | |||
| 8b7d72d046 | |||
| c217ebe2ad | |||
| 4acdeffbe9 | |||
| dc82a446b9 | |||
| c4bea9d558 | |||
| 1190ef3818 | |||
| 055a380e66 | |||
| 456c26a012 | |||
| 6072fc5c1b | |||
| 20df2fdba6 | |||
| f1399dcb65 | |||
| 3507bd10b6 | |||
| 492ae1fdfa | |||
| b00d8d65fe | |||
| f1366d80c8 | |||
| 549a8f826b | |||
| 59970699d0 | |||
| b35c5fc985 | |||
| 627e67323c | |||
| b97d964e5c | |||
| 3fd3e28690 | |||
| b4abeb4788 | |||
| 14deb6baf7 | |||
| ab6e18e4ff | |||
| 2b6dd2559c | |||
| 2b7bd84867 | |||
| baa54a5150 | |||
| c5704d3e3b | |||
| e3336725db | |||
| ff3ecefd24 | |||
| 8486bbcdc1 | |||
| 2621dde1c9 | |||
| a253c0437e | |||
| d8b3e528e8 |
@@ -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()
|
||||
|
||||
@@ -31,6 +31,9 @@ export default [
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@shikijs/stream", "katex", "marked", "marked-shiki", "remend"],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "virtual:opencode-app-assets" {
|
||||
const archive: string
|
||||
export default archive
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||
import { Subscription } from "@opencode-ai/console-core/subscription.js"
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const token = input.request.headers.get("authorization")?.match(/^Bearer (.+)$/)?.[1]
|
||||
if (!token) return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const row = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
balance: BillingTable.balance,
|
||||
monthlyLimit: BillingTable.monthlyLimit,
|
||||
monthlyUsage: BillingTable.monthlyUsage,
|
||||
useBalance: BillingTable.lite,
|
||||
rollingUsage: LiteTable.rollingUsage,
|
||||
weeklyUsage: LiteTable.weeklyUsage,
|
||||
goMonthlyUsage: LiteTable.monthlyUsage,
|
||||
timeRollingUpdated: LiteTable.timeRollingUpdated,
|
||||
timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
|
||||
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
|
||||
timeSubscribed: LiteTable.timeCreated,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
|
||||
.leftJoin(
|
||||
LiteTable,
|
||||
and(
|
||||
eq(LiteTable.workspaceID, KeyTable.workspaceID),
|
||||
eq(LiteTable.userID, KeyTable.userID),
|
||||
isNull(LiteTable.timeDeleted),
|
||||
),
|
||||
)
|
||||
.where(and(eq(KeyTable.key, token), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!row) return Response.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const limits = row.timeSubscribed ? LiteData.getLimits() : undefined
|
||||
return Response.json({
|
||||
go:
|
||||
limits && row.timeSubscribed
|
||||
? {
|
||||
useBalance: row.useBalance?.useBalance ?? false,
|
||||
rolling: Subscription.analyzeRollingUsage({
|
||||
limit: limits.rollingLimit,
|
||||
window: limits.rollingWindow,
|
||||
usage: row.rollingUsage ?? 0,
|
||||
timeUpdated: row.timeRollingUpdated ?? new Date(),
|
||||
}),
|
||||
weekly: Subscription.analyzeWeeklyUsage({
|
||||
limit: limits.weeklyLimit,
|
||||
usage: row.weeklyUsage ?? 0,
|
||||
timeUpdated: row.timeWeeklyUpdated ?? new Date(),
|
||||
}),
|
||||
monthly: Subscription.analyzeMonthlyUsage({
|
||||
limit: limits.monthlyLimit,
|
||||
usage: row.goMonthlyUsage ?? 0,
|
||||
timeUpdated: row.timeMonthlyUpdated ?? new Date(),
|
||||
timeSubscribed: row.timeSubscribed,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
zen: {
|
||||
balance: row.balance / 100_000_000,
|
||||
monthly: {
|
||||
usage: (row.monthlyUsage ?? 0) / 100_000_000,
|
||||
limit: row.monthlyLimit ?? undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -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", "*")
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
export * as CodeModeCatalog from "./catalog"
|
||||
export * as CodeModeCatalog from "./catalog.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
|
||||
@@ -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"}.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
export * as ConfigMarkdown from "./markdown"
|
||||
export * as ConfigMarkdown from "./markdown.js"
|
||||
|
||||
import matter from "gray-matter"
|
||||
export function parse(content: string) {
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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,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,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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user