mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1197209531 | |||
| 1c53c90d4e | |||
| edfd0bdb0b | |||
| 1b2e4750e1 | |||
| 59642a436f | |||
| 055eb78f06 | |||
| 722e0a04b2 |
@@ -124,66 +124,12 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
name: opencode-preview-cli
|
||||
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'
|
||||
@@ -522,7 +468,6 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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`?
|
||||
@@ -348,10 +348,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -395,10 +392,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
messages.push({ role: "user", content })
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
Vendored
-36
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-converse",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"parallel"
|
||||
],
|
||||
"name": "bedrock-converse/continues-after-parallel-tool-results",
|
||||
"recordedAt": "2026-08-11T16:41:46.482Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Compare the weather in Paris and London.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"weather_paris\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"weather_london\",\"name\":\"get_weather\",\"input\":{\"city\":\"London\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"weather_paris\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}},{\"toolResult\":{\"toolUseId\":\"weather_london\",\"content\":[{\"json\":{\"temperature\":14,\"condition\":\"rainy\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"After receiving both tool results, reply exactly: Paris is sunny; London is rainy.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAqAAAAFKgEDvmCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFEiLCJyb2xlIjoiYXNzaXN0YW50In189ig4AAAAzgAAAFfGCE2ECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlBhcmlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifWttETIAAADDAAAAVz6YiTULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE0ifRHJ8Q0AAACqAAAAV6q6nAkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHN1bm55In0sInAiOiJhYmNkZWZnaGlqayJ98ZCy6gAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiI7In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2In020bBKAAAAygAAAFcziOtECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBMb25kb24ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUCJ9ew04hAAAAK4AAABXXzo6yQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9yK3bdAAAALcAAABXMsrPOgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcmFpbnkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eCJ9JoCYVwAAALoAAABXyloLiws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9WJwR8wAAAMEAAABXRFjaVQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU4ifYzp4V0AAAChAAAAVqptnY4LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQyJ9AHyeLwAAAJUAAABRYKgWaws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0Iiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2HCXz0AAABBgAAAE6wWpX7CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTA5MH0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo1MjEsIm91dHB1dFRva2VucyI6OSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjUzMH19Uwfxiw==",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -255,57 +255,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_parallel_history",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Compare the weather."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
|
||||
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Compare the weather." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
|
||||
{ toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_paris",
|
||||
content: [{ json: { forecast: "sunny" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_london",
|
||||
content: [{ json: { forecast: "rainy" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers image content in tool-result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1216,39 +1165,4 @@ describe("Bedrock Converse recorded", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_parallel_tool_results",
|
||||
model: recordedModel(),
|
||||
system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
|
||||
messages: [
|
||||
Message.user("Compare the weather in Paris and London."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "weather_paris",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 22, condition: "sunny" },
|
||||
}),
|
||||
Message.tool({
|
||||
id: "weather_london",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 14, condition: "rainy" },
|
||||
}),
|
||||
],
|
||||
tools: [weatherTool],
|
||||
cache: "none",
|
||||
generation: { maxTokens: 40, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 }
|
||||
@@ -32,14 +31,7 @@ export function useUpdaterAction() {
|
||||
action,
|
||||
async run() {
|
||||
const run = action().run
|
||||
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 === "install") return platform.updater?.install()
|
||||
if (run !== "check") return
|
||||
|
||||
const state = await platform.updater?.check()
|
||||
|
||||
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers durable selection predecessors and derives them for older events", () => {
|
||||
const source: SessionMessageInfo[] = [
|
||||
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_previous_model",
|
||||
type: "model-switched",
|
||||
model: { id: "old", providerID: "provider" },
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
const reducer = createV2SessionReducer()
|
||||
|
||||
const agent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
|
||||
}),
|
||||
)
|
||||
const model = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_model",
|
||||
type: "session.model.selected",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
model: { id: "new", providerID: "provider" },
|
||||
previous: { id: "durable", providerID: "provider" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const legacyAgent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_legacy_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
|
||||
expect(model?.messages.at(-1)).toMatchObject({
|
||||
type: "model-switched",
|
||||
model: { id: "new" },
|
||||
previous: { id: "durable" },
|
||||
})
|
||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
||||
type: "agent-switched",
|
||||
agent: "plan",
|
||||
previous: "build",
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
|
||||
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
|
||||
item.type === "agent-switched" || item.type === "assistant",
|
||||
)?.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
|
||||
@@ -31,9 +31,6 @@ export default [
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@shikijs/stream", "katex", "marked", "marked-shiki", "remend"],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,7 +12,6 @@ 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, "..")
|
||||
@@ -56,21 +55,13 @@ 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,
|
||||
appArchive,
|
||||
}
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
@@ -24,7 +23,6 @@ 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: {
|
||||
@@ -56,20 +54,6 @@ 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"}` : ""}`
|
||||
@@ -96,7 +80,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?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>
|
||||
@@ -422,7 +422,8 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
variant:
|
||||
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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 and web server",
|
||||
description: "Start the v2 API server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
@@ -29,6 +28,7 @@ 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,60 +49,38 @@ 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")(() =>
|
||||
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 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 update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||
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 }))),
|
||||
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 }))),
|
||||
)
|
||||
|
||||
return Service.of({ path: file, get, update })
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
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 { randomUUID } from "crypto"
|
||||
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { Info } from "./schema"
|
||||
import type { 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
|
||||
@@ -20,60 +15,7 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
readonly state: string
|
||||
}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
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)
|
||||
}
|
||||
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||
|
||||
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||
@@ -81,59 +23,19 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
const migrated = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(migrated).length) return
|
||||
|
||||
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
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
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) =>
|
||||
@@ -147,16 +49,6 @@ 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
|
||||
@@ -167,7 +59,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(keybinds === undefined ? {} : { keybinds }),
|
||||
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||
...(plugins.length ? { plugins } : {}),
|
||||
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
|
||||
@@ -44,16 +43,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)
|
||||
const incumbent =
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return
|
||||
if (
|
||||
serviceOptions !== undefined &&
|
||||
port !== undefined &&
|
||||
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== 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.
|
||||
@@ -69,7 +68,6 @@ 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: {
|
||||
@@ -124,7 +122,6 @@ 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)
|
||||
@@ -146,7 +143,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 (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
if (options.mode === "default" && !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"
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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
@@ -1,4 +0,0 @@
|
||||
declare module "virtual:opencode-app-assets" {
|
||||
const archive: string
|
||||
export default archive
|
||||
}
|
||||
@@ -3,38 +3,6 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
|
||||
describe("acp service lifecycle", () => {
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
|
||||
await using fixture = makeACPFixture({
|
||||
models: [model],
|
||||
defaultModel: model,
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_default_variant", {
|
||||
model: { providerID: model.providerID, id: model.id },
|
||||
}),
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/session",
|
||||
query: {},
|
||||
body: {
|
||||
location: { directory: "/workspace" },
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "second-model" },
|
||||
},
|
||||
})
|
||||
expect(currentValue(created, "effort")).toBe("none")
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import { parse } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { Config } from "../src/config"
|
||||
|
||||
@@ -23,14 +21,7 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
path.join(directory, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "legacy",
|
||||
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",
|
||||
},
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugin: [["example", { mode: "safe" }]],
|
||||
plugin_enabled: { disabled: false },
|
||||
leader_timeout: 500,
|
||||
@@ -74,13 +65,7 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
|
||||
expect(config).toMatchObject({
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: {
|
||||
leader: "ctrl+o",
|
||||
"app.exit": "ctrl+q",
|
||||
"prompt.paste": { key: "ctrl+v", preventDefault: false },
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
},
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
@@ -95,13 +80,7 @@ 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",
|
||||
"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")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
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)
|
||||
@@ -162,257 +141,6 @@ 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')
|
||||
@@ -439,15 +167,3 @@ 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}`)
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}),
|
||||
)
|
||||
@@ -1,69 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
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,19 +17,6 @@ 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",
|
||||
@@ -225,14 +212,12 @@ 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(),
|
||||
@@ -274,5 +259,4 @@ export default mainConfig({
|
||||
models: "undefined",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
appArchive: "",
|
||||
})
|
||||
|
||||
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.agent.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly previous?: Agent.ID | undefined
|
||||
}
|
||||
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.model.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly model: Model.Ref
|
||||
readonly previous?: Model.Ref | undefined
|
||||
}
|
||||
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
|
||||
@@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) {
|
||||
request<ProjectCopyCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
path: `/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: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
path: `/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: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
|
||||
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
|
||||
type: "session.agent.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; agent: string; previous?: string }
|
||||
data: { sessionID: string; agent: string }
|
||||
}
|
||||
|
||||
export type SessionModelSelected = {
|
||||
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
|
||||
type: "session.model.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
|
||||
data: { sessionID: string; model: ModelRef }
|
||||
}
|
||||
|
||||
export type SessionMoved = {
|
||||
|
||||
@@ -9310,7 +9310,7 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/api/experimental/project/{projectID}/copy": {
|
||||
"/experimental/project/{projectID}/copy": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.create",
|
||||
@@ -9536,7 +9536,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/project/{projectID}/copy/refresh": {
|
||||
"/experimental/project/{projectID}/copy/refresh": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.refresh",
|
||||
|
||||
@@ -25,10 +25,9 @@
|
||||
},
|
||||
"imports": {
|
||||
"#sqlite": {
|
||||
"workerd": "./src/database/sqlite.workerd.ts",
|
||||
"bun": "./src/database/sqlite.bun.ts",
|
||||
"node": "./src/database/sqlite.node.ts",
|
||||
"default": "./src/database/sqlite.node.ts"
|
||||
"default": "./src/database/sqlite.bun.ts"
|
||||
},
|
||||
"#pty": {
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
|
||||
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
|
||||
"id": "15060ec5-05f7-4b86-b2a5-9108609432b3",
|
||||
"prevIds": ["1551a157-8959-4ba9-a52b-4ea3b7b28cae"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1302,16 +1302,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "resume_attempts",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().primaryKey(),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export * as Agent from "./agent.js"
|
||||
export * as Agent from "./agent"
|
||||
|
||||
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.js"
|
||||
import { State } from "./state.js"
|
||||
import { Bus } from "./bus"
|
||||
import { State } from "./state"
|
||||
|
||||
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.js"
|
||||
export * as AISDKNative from "./aisdk-native"
|
||||
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Provider } from "./provider.js"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export interface Mapping {
|
||||
readonly package: string
|
||||
@@ -51,27 +51,6 @@ 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":
|
||||
@@ -109,13 +88,9 @@ function mapBedrockSettings(
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const region = bedrockRegion(settings)
|
||||
const credentials = mapBedrockCredentials(settings, region)
|
||||
const credentials = mapBedrockCredentials(settings)
|
||||
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 }
|
||||
: {}),
|
||||
@@ -180,8 +155,14 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
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" ||
|
||||
@@ -196,15 +177,6 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, regi
|
||||
}
|
||||
}
|
||||
|
||||
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.js"
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
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.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { State } from "./state.js"
|
||||
import type { ID, Info } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as App from "./app.js"
|
||||
export * as App from "./app"
|
||||
|
||||
import { Context, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export * as Bus from "./bus.js"
|
||||
export * as Bus from "./bus"
|
||||
|
||||
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.js"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
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.js"
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
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.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { State } from "./state.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Model } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { Bus } from "./bus"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: Provider.MutableInfo
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as CodeModeCatalog from "./catalog.js"
|
||||
export * as CodeModeCatalog from "./catalog"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as CodeModeInstructions from "./instructions.js"
|
||||
export * as CodeModeInstructions from "./instructions"
|
||||
|
||||
import { searchSignature, toolExpression } from "@opencode-ai/codemode"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// 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.js"
|
||||
export * as CodeModeTool from "./tool"
|
||||
|
||||
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.js"
|
||||
import { definition } from "../tool/runtime"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export * as Command from "./command.js"
|
||||
export * as Command from "./command"
|
||||
|
||||
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.js"
|
||||
import { MCP } from "./mcp/index.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { State } from "./state"
|
||||
import { MCP } from "./mcp/index"
|
||||
import { Bus } from "./bus"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config.js"
|
||||
import { Location } from "./location.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
export const Info = Command.Info
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
export * as Config from "./config.js"
|
||||
export * as Config from "./config"
|
||||
|
||||
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.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Watcher } from "./filesystem/watcher.js"
|
||||
import { Credential } from "./credential"
|
||||
import { Bus } from "./bus"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
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"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigVariable } from "./config/variable"
|
||||
import { ConfigNormalize } from "./config/normalize"
|
||||
import { WellKnown } from "./wellknown"
|
||||
|
||||
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").toReversed(),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
]),
|
||||
].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").toReversed(),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigMarkdown from "./markdown.js"
|
||||
export * as ConfigMarkdown from "./markdown"
|
||||
|
||||
import matter from "gray-matter"
|
||||
export function parse(content: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigNormalize from "./normalize.js"
|
||||
export * as ConfigNormalize from "./normalize"
|
||||
|
||||
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.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"
|
||||
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"
|
||||
|
||||
export interface Diagnostic {
|
||||
readonly kind: "conflict" | "invalid" | "unsupported"
|
||||
@@ -83,14 +83,8 @@ export function normalize(input: unknown): Result {
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
const nativeReferences = decodeMap(
|
||||
input.references,
|
||||
ConfigReference.Entry,
|
||||
["references"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
)
|
||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"references",
|
||||
@@ -100,13 +94,13 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
||||
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 = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
|
||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"commands",
|
||||
@@ -116,9 +110,8 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyAgents = mapValues(
|
||||
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
|
||||
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
)
|
||||
const legacySmallModel = own(input, "small_model")
|
||||
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||
@@ -137,11 +130,11 @@ export function normalize(input: unknown): Result {
|
||||
model: migratedSmallModel,
|
||||
...legacyAgents.title,
|
||||
}
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||
)
|
||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
|
||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
||||
mergeMap(
|
||||
@@ -154,7 +147,7 @@ export function normalize(input: unknown): Result {
|
||||
)
|
||||
|
||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
||||
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
|
||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"providers",
|
||||
@@ -166,14 +159,14 @@ export function normalize(input: unknown): Result {
|
||||
|
||||
const toolRules = migrateTools(input.tools, diagnostics)
|
||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
||||
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
|
||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
||||
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
|
||||
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
)
|
||||
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
|
||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
||||
|
||||
@@ -207,7 +200,7 @@ export function normalize(input: unknown): Result {
|
||||
overlay(encoded, key, value, [key], diagnostics)
|
||||
})
|
||||
|
||||
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
|
||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
||||
|
||||
return { type: "normalized", encoded, diagnostics }
|
||||
@@ -216,7 +209,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 = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
|
||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
||||
return
|
||||
}
|
||||
if (!isRecord(input.skills)) {
|
||||
@@ -224,8 +217,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
|
||||
return
|
||||
}
|
||||
encoded.skills = [
|
||||
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
|
||||
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
|
||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -255,8 +248,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
return
|
||||
}
|
||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
||||
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
|
||||
([key, server]) => setOwn(nativeServers, key, server),
|
||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
||||
setOwn(nativeServers, key, server),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -411,13 +404,7 @@ function normalizeExperimental(
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
native.push(
|
||||
...decodeList(
|
||||
experimental.policies,
|
||||
ConfigPolicy.Info,
|
||||
["experimental", "policies"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
),
|
||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -433,7 +420,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
|
||||
invalid(["watcher"], diagnostics)
|
||||
return
|
||||
}
|
||||
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
|
||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
||||
}
|
||||
|
||||
@@ -448,7 +435,7 @@ function normalizeFormatter(
|
||||
if (value !== undefined) encoded.formatter = value
|
||||
return
|
||||
}
|
||||
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
|
||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
||||
encoded.formatter = entries
|
||||
}
|
||||
@@ -460,7 +447,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
if (value !== undefined) encoded.lsp = value
|
||||
return
|
||||
}
|
||||
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
|
||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
||||
}
|
||||
|
||||
@@ -610,44 +597,78 @@ function decodeProviderList(
|
||||
return {
|
||||
present: true,
|
||||
nonEmpty: input[key].length > 0,
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
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]): [string, A][] => {
|
||||
const decoded = decode(schema, raw, [...path, name], diagnostics)
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
|
||||
): A[] {
|
||||
if (value === undefined) return []
|
||||
) {
|
||||
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"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return []
|
||||
return [] as S["Encoded"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
|
||||
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)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
export * as ConfigAgentPlugin from "./agent.js"
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
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.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
import { Agent } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent.js"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
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"
|
||||
import { Permission } from "../../permission"
|
||||
import type { LocationMutation } from "../../location-mutation"
|
||||
import type { ReadTool } from "../../tool/plugin/read"
|
||||
import type { EditTool } from "../../tool/plugin/edit"
|
||||
|
||||
const legacySources = [
|
||||
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
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.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Command } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigInstructionPlugin from "./instruction.js"
|
||||
export * as ConfigInstructionPlugin from "./instruction"
|
||||
|
||||
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.js"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery.js"
|
||||
import { Instructions } from "../../instructions/index.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery"
|
||||
import { Instructions } from "../../instructions/index"
|
||||
import { Location } from "../../location"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
@@ -27,10 +27,8 @@ export const Plugin = define({
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
const home = yield* fs.resolve(global.home)
|
||||
const project = discovery.project && FSUtil.contains(root, start)
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const project = discovery.project && FSUtil.contains(stop, start)
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as ConfigPolicyPlugin from "./policy.js"
|
||||
export * as ConfigPolicyPlugin from "./policy"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document } from "@opencode-ai/schema/config"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Wildcard } from "../../util/wildcard.js"
|
||||
import { Config } from "../../config"
|
||||
import { Wildcard } from "../../util/wildcard"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.policy",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export * as ConfigProviderPlugin from "./provider.js"
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
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.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { Config } from "../../config"
|
||||
import { Provider } from "../../provider"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.provider",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigReferencePlugin from "./reference.js"
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
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.js"
|
||||
import { Reference } from "../../reference.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Config } from "../../config"
|
||||
import { Reference } from "../../reference"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location.js"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.reference",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as SkillFile from "./skill-file.js"
|
||||
export * as SkillFile from "./skill-file"
|
||||
|
||||
import path from "path"
|
||||
import { Result, Schema, type SchemaIssue, SchemaParser } from "effect"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Skill } from "../../skill.js"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
|
||||
const Frontmatter = Schema.Struct({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as ConfigSkillPlugin from "./skill.js"
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
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.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"
|
||||
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"
|
||||
|
||||
type Source = Skill.DirectorySource | Skill.UrlSource
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigPluginSource from "./source.js"
|
||||
export * as ConfigPluginSource from "./source"
|
||||
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { Config } from "../../config"
|
||||
import { Watcher } from "../../filesystem/watcher"
|
||||
import { Location } from "../../location"
|
||||
|
||||
export type Operation =
|
||||
| {
|
||||
@@ -154,67 +154,19 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
})
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
const Package = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
module: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
const decodePackage = Schema.decodeUnknownOption(Package)
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
|
||||
fs.readDirectoryEntries(path.join(directory, source)).pipe(
|
||||
Effect.orElseSucceed(() => []),
|
||||
Effect.map((entries) =>
|
||||
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
|
||||
),
|
||||
),
|
||||
))
|
||||
.flat()
|
||||
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
|
||||
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
|
||||
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
|
||||
if (entry.type === "file" && source) return Option.some(entry.target)
|
||||
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
|
||||
if (entry.type !== "symlink") return Option.none<string>()
|
||||
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
|
||||
return Option.none<string>()
|
||||
})
|
||||
}
|
||||
|
||||
function discoverPackage(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* fs.resolve(directory)
|
||||
const manifest = yield* fs
|
||||
.readJson(path.join(directory, "package.json"))
|
||||
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
|
||||
const configured = Option.isSome(manifest)
|
||||
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
|
||||
: []
|
||||
return yield* Effect.findFirst(
|
||||
[...configured, "index.ts", "index.js"]
|
||||
.filter((entry) => !path.isAbsolute(entry))
|
||||
.map((entry) => path.resolve(directory, entry))
|
||||
.filter((entry) => FSUtil.contains(directory, entry)),
|
||||
(entry) =>
|
||||
fs
|
||||
.isFile(entry)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists
|
||||
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
const files = yield* fs
|
||||
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export * as ConfigWebSearchPlugin from "./websearch.js"
|
||||
export * as ConfigWebSearchPlugin from "./websearch"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Config } from "../../config"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.websearch",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as ConfigVariable from "./variable.js"
|
||||
export * as ConfigVariable from "./variable"
|
||||
|
||||
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.js"
|
||||
import { InvalidError } from "../v1/config/error"
|
||||
|
||||
type ParseSource =
|
||||
| {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export * as Credential from "./credential.js"
|
||||
export * as Credential from "./credential"
|
||||
|
||||
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.js"
|
||||
import { Database } from "./database/database"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql.js"
|
||||
import { CredentialTable } from "./credential/sql"
|
||||
|
||||
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.js"
|
||||
import type { Credential } from "../credential.js"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { Credential } from "../credential"
|
||||
|
||||
export const CredentialTable = sqliteTable("credential", {
|
||||
id: text().$type<Credential.ID>().primaryKey(),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
export * as Database from "./database.js"
|
||||
export * as Database from "./database"
|
||||
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
|
||||
import { sqliteLayer } 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.js"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -28,15 +27,12 @@ const databaseLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
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* 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)")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
@@ -46,7 +42,7 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
@@ -55,12 +51,6 @@ 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] })
|
||||
}
|
||||
|
||||
+42
-44
@@ -1,46 +1,45 @@
|
||||
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"
|
||||
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"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -84,5 +83,4 @@ export const migrations = [
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
m41,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export * as DatabaseMigration from "./migration.js"
|
||||
export * as DatabaseMigration from "./migration"
|
||||
|
||||
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.js"
|
||||
import schema from "./schema.gen.js"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
@@ -21,10 +20,8 @@ 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_%' AND substr(name, 1, 1) <> '_'`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
@@ -106,15 +103,9 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
// 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* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).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.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260127222353_familiar_lady_ursula",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260211171708_add_project_commands",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260213144116_wakeful_the_professor",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260225215848_workspace",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260227213759_add_session_workspace_id",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260228203230_blue_harpoon",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260303231226_add_workspace_fields",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260309230000_move_org_to_state",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260312043431_session_message_cursor",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260323234822_events",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260410174513_workspace-name",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260413175956_chief_energizer",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260423070820_add_icon_url_override",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260427172553_slow_nightmare",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260428004200_add_session_path",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260501142318_next_venus",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260504145000_add_sync_owner",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260507164347_add_workspace_time",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260510033149_session_usage",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260511000411_data_migration_state",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260511173437_session-metadata",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260601010001_normalize_storage_paths",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260601202201_amazing_prowler",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260602002951_lowly_union_jack",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260602182828_add_project_directories",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
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.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260603040000_session_message_projection_order",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260603141458_session_input_inbox",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260603160727_jittery_ezekiel_stane",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260604172448_event_sourced_session_input",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260605003541_add_session_context_snapshot",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260605042240_add_context_epoch_agent",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260611035744_credential",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260611192811_lush_chimera",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260612174303_project_dir_strategy",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260622142730_simplify_session_context_epoch",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260622170816_reset_v2_session_state",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user