mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-20 07:37:35 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
547afa509e |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,10 +201,11 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -214,7 +215,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -250,7 +251,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -277,7 +278,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -294,7 +295,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -318,7 +319,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -338,7 +339,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -406,7 +407,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -455,7 +456,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -527,7 +528,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -540,7 +541,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -554,7 +555,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -569,7 +570,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -608,7 +609,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -638,7 +639,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -653,7 +654,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -677,7 +678,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -698,7 +699,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -720,7 +721,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -755,7 +756,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -775,7 +776,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -809,7 +810,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -828,7 +829,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -874,7 +875,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -888,7 +889,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -923,7 +924,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,7 +959,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -991,7 +992,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1032,7 +1033,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
|
||||
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
|
||||
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
|
||||
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
|
||||
"x86_64-linux": "sha256-TRfKunG6/UE8rQFyRkx/pX+pe7GT542IJWLEKcFFfAY=",
|
||||
"aarch64-linux": "sha256-JxCYBQoHeJVuEMEe4Ef5f6UxxUCAuhPo96jqMT047Fc=",
|
||||
"aarch64-darwin": "sha256-UEgjeQMivNC7JVsLEDlNbuqp9LJH84f9nHgLHTZ2zDI=",
|
||||
"x86_64-darwin": "sha256-jEs/Oadjf2LVAinY0xyFna0V+NTwoCKXiXmQ83OchF8="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
|
||||
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
})
|
||||
|
||||
test("reorders session tabs with touch", async ({ page }) => {
|
||||
const tabs = page.locator('[data-slot="vertical-tabs"] a')
|
||||
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
|
||||
|
||||
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
|
||||
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
|
||||
const targetBox = await target.boundingBox()
|
||||
const sourceBox = await source.boundingBox()
|
||||
expect(targetBox).not.toBeNull()
|
||||
expect(sourceBox).not.toBeNull()
|
||||
|
||||
await touchDrag(page, source, {
|
||||
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
|
||||
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
|
||||
})
|
||||
|
||||
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
|
||||
})
|
||||
|
||||
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
|
||||
const home = drawer.getByRole("button", { name: "Home", exact: true })
|
||||
const homeBox = await home.boundingBox()
|
||||
const box = await tab.boundingBox()
|
||||
expect(homeBox).not.toBeNull()
|
||||
expect(box).not.toBeNull()
|
||||
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
|
||||
await tab.dispatchEvent("pointerdown", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
|
||||
await expect(rename).toBeVisible()
|
||||
await expect(drawer).toBeVisible()
|
||||
await tab.filter({ visible: true }).dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
|
||||
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
|
||||
await expect(rename).toBeHidden()
|
||||
await expect(drawer).toBeVisible()
|
||||
})
|
||||
|
||||
async function touchDrag(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
input: { from: { x: number; y: number }; to: { x: number; y: number } },
|
||||
) {
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ ...input.from, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
|
||||
})
|
||||
await expect(source).toHaveCount(2)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ ...input.to, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
}
|
||||
@@ -166,45 +166,6 @@ test.describe("session timeline projection", () => {
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not repeat a review comment file as an attachment", async ({ page }) => {
|
||||
const message = userMessage([
|
||||
userText("what's goin on here", { id: "prt_user_review_comment" }),
|
||||
{
|
||||
id: "prt_user_review_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "LiveRuntime.ts",
|
||||
url: "file:///repo/LiveRuntime.ts?start=14&end=32",
|
||||
},
|
||||
{
|
||||
id: "prt_user_unrelated_file",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "notes.txt",
|
||||
url: "data:text/plain;base64,bm90ZXM=",
|
||||
},
|
||||
])
|
||||
message.metadata = {
|
||||
displayText: "what's goin on here",
|
||||
comments: [
|
||||
{
|
||||
path: "LiveRuntime.ts",
|
||||
comment: "what's goin on here",
|
||||
selection: { startLine: 14, startChar: 0, endLine: 32, endChar: 0 },
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await setupTimeline(page, { messages: [message, assistantMessage()] })
|
||||
|
||||
const user = page.locator('[data-component="user-message"]')
|
||||
await expect(user.getByText("LiveRuntime.ts:14-32", { exact: true })).toBeVisible()
|
||||
const attachments = user.locator('[data-slot="user-message-attachments"]')
|
||||
await expect(attachments.getByText("LiveRuntime.ts", { exact: true })).toHaveCount(0)
|
||||
await expect(attachments.getByText("notes.txt", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, lazy, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -18,6 +17,8 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
|
||||
const Icon = lazy(() => import("@opencode/ui/icon").then((module) => ({ default: module.Icon })))
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { canOpenTabRename, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
|
||||
import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./tab-gesture"
|
||||
|
||||
describe("titlebar tab gestures", () => {
|
||||
test("excludes close controls from tab gestures", () => {
|
||||
@@ -24,4 +24,10 @@ describe("titlebar tab gestures", () => {
|
||||
expect(canOpenTabRename(false, false, false)).toBe(true)
|
||||
expect(canOpenTabRename(false, false, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves native panning for touch pointers", () => {
|
||||
expect(canStartTabDrag("mouse")).toBe(true)
|
||||
expect(canStartTabDrag("pen")).toBe(true)
|
||||
expect(canStartTabDrag("touch")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ export function isTabCloseTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && !!target.closest('[data-slot="tab-close"]')
|
||||
}
|
||||
|
||||
export function canStartTabDrag(pointerType: string) {
|
||||
return pointerType !== "touch"
|
||||
}
|
||||
|
||||
export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTMLDivElement) {
|
||||
if (typeof ref === "function") ref(element)
|
||||
}
|
||||
|
||||
@@ -328,7 +328,6 @@ export function TabNavItem(props: {
|
||||
|
||||
return (
|
||||
<Menu.Context
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
setMenu("open", open)
|
||||
if (open) setPopoverOpen(false)
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { createTabComposerState } from "@/composer/persistence"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { isTabCloseTarget } from "./tab-gesture"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./tab-gesture"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
|
||||
@@ -292,11 +292,9 @@ export function TitlebarTabStrip(props: {
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: (event) =>
|
||||
event.pointerType === "touch"
|
||||
? [new PointerActivationConstraints.Distance({ value: 8 })]
|
||||
: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
!canStartTabDrag(event.pointerType) ||
|
||||
isTabCloseTarget(event.target) ||
|
||||
(event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')),
|
||||
}),
|
||||
@@ -315,7 +313,6 @@ export function TitlebarTabStrip(props: {
|
||||
if (!source) return
|
||||
const tab = props.tabs.find((item) => tabKey(item) === source.id.toString())
|
||||
if (!tab) return
|
||||
if (vertical()) return
|
||||
const tabEl = source.element?.querySelector<HTMLDivElement>("[data-titlebar-tab]")
|
||||
props.onNavigate(tab, tabEl ?? undefined)
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Script } from "@opencode/script"
|
||||
import { $ } from "bun"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
if (Script.channel !== "beta" && Script.channel !== "latest") {
|
||||
throw new Error("Homebrew publishing requires the beta or latest channel")
|
||||
}
|
||||
const valid =
|
||||
Script.channel === "beta"
|
||||
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
|
||||
: /^\d+\.\d+\.\d+$/.test(Script.version)
|
||||
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
|
||||
|
||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
|
||||
const outdir = path.join(root, "homebrew-tap")
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
const name = Script.channel === "beta" ? "opencode-beta" : "opencode-v2"
|
||||
const formulaClass = Script.channel === "beta" ? "OpencodeBeta" : "OpencodeV2"
|
||||
|
||||
const targets = await Promise.all(
|
||||
[
|
||||
{ name: "darwin-arm64", archive: "zip" },
|
||||
{ name: "darwin-x64-baseline", archive: "zip" },
|
||||
{ name: "linux-arm64", archive: "tar.gz" },
|
||||
{ name: "linux-x64-baseline", archive: "tar.gz" },
|
||||
].map(async (target) => {
|
||||
const filename = `opencode-${target.name}.${target.archive}`
|
||||
const file = Bun.file(path.join(root, filename))
|
||||
if (!(await file.exists()) || !file.size) throw new Error(`Missing Homebrew archive: ${filename}`)
|
||||
const sha256 = new Bun.CryptoHasher("sha256")
|
||||
for await (const chunk of file.stream()) sha256.update(chunk)
|
||||
return {
|
||||
...target,
|
||||
url: `https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${filename}`,
|
||||
sha256: sha256.digest("hex"),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
await rm(outdir, { recursive: true, force: true })
|
||||
if (dryRun) await mkdir(outdir, { recursive: true })
|
||||
if (!dryRun) {
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GITHUB_TOKEN is required to update the Homebrew tap")
|
||||
await $`git clone ${`https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`} ${outdir}`
|
||||
await $`git checkout -B master`.cwd(outdir)
|
||||
}
|
||||
|
||||
const target = (name: string) => {
|
||||
const result = targets.find((item) => item.name === name)
|
||||
if (!result) throw new Error(`Missing Homebrew target: ${name}`)
|
||||
return result
|
||||
}
|
||||
const macArm = target("darwin-arm64")
|
||||
const macIntel = target("darwin-x64-baseline")
|
||||
const linuxArm = target("linux-arm64")
|
||||
const linuxIntel = target("linux-x64-baseline")
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, `${name}.rb`),
|
||||
[
|
||||
"# typed: false",
|
||||
"# frozen_string_literal: true",
|
||||
"",
|
||||
`class ${formulaClass} < Formula`,
|
||||
` desc "OpenCode V2${Script.channel === "beta" ? " beta" : ""} - the AI coding agent for the terminal"`,
|
||||
' homepage "https://github.com/anomalyco/opencode"',
|
||||
` version "${Script.version}"`,
|
||||
' license "MIT"',
|
||||
"",
|
||||
' depends_on "ripgrep"',
|
||||
' conflicts_with "opencode", because: "both install an opencode binary"',
|
||||
"",
|
||||
" on_macos do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${macArm.url}"`,
|
||||
` sha256 "${macArm.sha256}"`,
|
||||
" else",
|
||||
` url "${macIntel.url}"`,
|
||||
` sha256 "${macIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" on_linux do",
|
||||
" if Hardware::CPU.arm?",
|
||||
` url "${linuxArm.url}"`,
|
||||
` sha256 "${linuxArm.sha256}"`,
|
||||
" else",
|
||||
` url "${linuxIntel.url}"`,
|
||||
` sha256 "${linuxIntel.sha256}"`,
|
||||
" end",
|
||||
" end",
|
||||
"",
|
||||
" def install",
|
||||
' bin.install "opencode"',
|
||||
" end",
|
||||
"end",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
console.log(`Prepared ${name} ${Script.version} in ${outdir}`)
|
||||
if (dryRun) process.exit(0)
|
||||
|
||||
await $`git add ${name + ".rb"}`.cwd(outdir)
|
||||
if ((await $`git diff --cached --quiet`.cwd(outdir).nothrow()).exitCode !== 0) {
|
||||
await $`git commit -m ${`chore: update ${name} to ${Script.version}`}`.cwd(outdir)
|
||||
await $`git push origin master`.cwd(outdir)
|
||||
}
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "homebrew",
|
||||
version: Script.version,
|
||||
metadata: { package: `anomalyco/tap/${name}` },
|
||||
})
|
||||
@@ -149,10 +149,6 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
|
||||
|
||||
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
|
||||
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
|
||||
await $`bun ./script/publish-homebrew.ts ${dryRun ? ["--dry-run"] : []}`.env({
|
||||
...process.env,
|
||||
OPENCODE_CLI_DIST: root,
|
||||
})
|
||||
}
|
||||
|
||||
async function archive(bin: string, target: string, binary: string, directory: string) {
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
const decodeVpPackages = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
|
||||
)
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
@@ -101,13 +96,6 @@ const make = Effect.gen(function* () {
|
||||
process.platform === "win32" ? "opencode.exe" : "opencode",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
if (
|
||||
["opencode-beta", "opencode-v2"].some((name) =>
|
||||
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
|
||||
)
|
||||
)
|
||||
return "brew"
|
||||
if (!installedPackage) return
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
@@ -115,30 +103,22 @@ const make = Effect.gen(function* () {
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
{ method: "vp", command: ["vp", "list", "-g", "--json", installedPackage] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => {
|
||||
if (result.check.method !== "vp") return result.result.stdout.includes(installedPackage)
|
||||
// Vite+ repeats the filter in its successful no-match message, so substring detection would be a false positive.
|
||||
return Option.exists(decodeVpPackages(result.result.stdout), (packages) =>
|
||||
packages.some((item) => item.name === installedPackage),
|
||||
)
|
||||
})?.check.method
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
})
|
||||
|
||||
const removal = (method: Method) => {
|
||||
if (method === "curl" || method === "brew" || !installedPackage) return undefined
|
||||
if (method === "curl" || !installedPackage) return undefined
|
||||
const commands = {
|
||||
npm: ["npm", "uninstall", "--global", installedPackage],
|
||||
pnpm: ["pnpm", "remove", "--global", installedPackage],
|
||||
bun: ["bun", "remove", "--global", installedPackage],
|
||||
yarn: ["yarn", "global", "remove", installedPackage],
|
||||
vp: ["vp", "uninstall", "-g", installedPackage],
|
||||
}
|
||||
const command = commands[method]
|
||||
return {
|
||||
@@ -153,12 +133,11 @@ const make = Effect.gen(function* () {
|
||||
}
|
||||
}
|
||||
|
||||
const release = Effect.fnUntraced(function* (method?: Method) {
|
||||
const distribution = method === "brew" ? "homebrew" : "npm"
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/${distribution}?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
`https://opencode.ai/update/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm?current=${encodeURIComponent(OPENCODE_VERSION)}`,
|
||||
{
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
@@ -174,11 +153,7 @@ const make = Effect.gen(function* () {
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = () =>
|
||||
method().pipe(
|
||||
Effect.flatMap(release),
|
||||
Effect.map((data) => data.version),
|
||||
)
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
|
||||
const temporaryDirectory = (prefix: string) =>
|
||||
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
|
||||
@@ -188,12 +163,12 @@ const make = Effect.gen(function* () {
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName = (yield* release(method)).package
|
||||
const packageName = (yield* release()).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
@@ -207,10 +182,6 @@ const make = Effect.gen(function* () {
|
||||
],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
vp:
|
||||
installedPackage && packageName !== installedPackage
|
||||
? ["vp", "install", "-g", "--force", target]
|
||||
: ["vp", "update", "-g", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -231,7 +202,6 @@ const make = Effect.gen(function* () {
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
|
||||
@@ -19,7 +19,6 @@ function fixture(
|
||||
} = () => ({}),
|
||||
name = "@opencode/cli",
|
||||
failCleanup = false,
|
||||
releasePackage = name,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
@@ -35,7 +34,7 @@ function fixture(
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: releasePackage } }), {
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
),
|
||||
@@ -107,7 +106,6 @@ const installs = [
|
||||
command: ["pnpm", "add", "--global", "--allow-build=@opencode/cli", "@opencode/cli@2.3.4-beta.1"],
|
||||
},
|
||||
{ method: "yarn", command: ["yarn", "global", "add", "@opencode/cli@2.3.4-beta.1"] },
|
||||
{ method: "vp", command: ["vp", "update", "-g", "@opencode/cli@2.3.4-beta.1"] },
|
||||
] as const
|
||||
|
||||
installs.forEach(({ method, command }) => {
|
||||
@@ -119,25 +117,6 @@ installs.forEach(({ method, command }) => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("vp force-installs a renamed V2 package that replaces the existing binary owner", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture(() => ({}), "@opencode/cli", false, "@opencode/cli-node")
|
||||
yield* test.updater.upgrade("vp", "v2.3.4-beta.1")
|
||||
expect(test.commands).toEqual([["vp", "install", "-g", "--force", "@opencode/cli-node@2.3.4-beta.1"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("vp removes the package from its managed global store", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture()
|
||||
const removal = test.updater.removal("vp")
|
||||
if (!removal) return yield* Effect.die("Expected vp removal command")
|
||||
expect(removal.command).toEqual(["vp", "uninstall", "-g", "@opencode/cli"])
|
||||
yield* removal.run
|
||||
expect(test.commands).toEqual([["vp", "uninstall", "-g", "@opencode/cli"]])
|
||||
}),
|
||||
)
|
||||
;[0, 1].forEach((exitCode) => {
|
||||
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -221,19 +200,11 @@ it.live("install failures expose stderr and process errors do not report success
|
||||
expect(missing.commands).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
;(["npm", "pnpm", "bun", "yarn", "vp", undefined] as const).forEach((method) => {
|
||||
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
|
||||
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(
|
||||
command.command === method
|
||||
? method === "vp"
|
||||
? JSON.stringify([{ name: "@opencode/cli", version: "2.3.4" }])
|
||||
: "@opencode/cli@2.3.4"
|
||||
: command.command === "vp"
|
||||
? "[]"
|
||||
: "opencode-ai@1.0.0",
|
||||
),
|
||||
stdout: Buffer.from(command.command === method ? "@opencode/cli@2.3.4" : "opencode-ai@1.0.0"),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBe(method)
|
||||
expect(test.commands).toEqual([
|
||||
@@ -241,7 +212,6 @@ it.live("install failures expose stderr and process errors do not report success
|
||||
["pnpm", "list", "-g", "--depth=0", "@opencode/cli"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
["vp", "list", "-g", "--json", "@opencode/cli"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -255,16 +225,7 @@ it.live("method detection tolerates unavailable package managers", () =>
|
||||
: { error: new AppProcess.AppProcessError({ command: command.command }) },
|
||||
)
|
||||
expect(yield* test.updater.method()).toBe("yarn")
|
||||
expect(test.commands).toHaveLength(5)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("vp detection ignores no-match output that repeats the package name", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === "vp" ? "No global packages matching '@opencode/cli'." : ""),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBeUndefined()
|
||||
expect(test.commands).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -304,16 +265,13 @@ if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-no
|
||||
expect(yield* test.updater.method()).toBe("npm")
|
||||
yield* test.updater.upgrade("npm", "v2.3.4")
|
||||
yield* test.updater.upgrade("pnpm", "v2.3.4")
|
||||
yield* test.updater.upgrade("vp", "v2.3.4")
|
||||
expect(test.commands).toEqual([
|
||||
["npm", "list", "-g", "--depth=0", "@opencode/cli-node"],
|
||||
["pnpm", "list", "-g", "--depth=0", "@opencode/cli-node"],
|
||||
["bun", "pm", "ls", "-g"],
|
||||
["yarn", "global", "list"],
|
||||
["vp", "list", "-g", "--json", "@opencode/cli-node"],
|
||||
["npm", "install", "--global", "@opencode/cli-node@2.3.4"],
|
||||
["pnpm", "add", "--global", "--allow-build=@opencode/cli-node", "@opencode/cli-node@2.3.4"],
|
||||
["vp", "update", "-g", "@opencode/cli-node@2.3.4"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -35,12 +35,6 @@ describe("upgrade command", () => {
|
||||
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
|
||||
})
|
||||
|
||||
test("accepts vp as an explicit installation method", async () => {
|
||||
const result = await cli(["2.0.0", "--method", "vp"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.events).toEqual([{ method: "vp", version: "2.0.0" }])
|
||||
})
|
||||
|
||||
test("skips the already installed version", async () => {
|
||||
const result = await cli(["v0.0.0-beta-old"])
|
||||
expect(result.exitCode).toBe(0)
|
||||
@@ -56,7 +50,7 @@ describe("upgrade command", () => {
|
||||
})
|
||||
|
||||
test("rejects unsupported methods before attempting an upgrade", async () => {
|
||||
const result = await cli(["--method", "apt"])
|
||||
const result = await cli(["--method", "brew"])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.events).toEqual([])
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -241,7 +241,6 @@ export type SessionSwitchModelOperation<E = never> = (
|
||||
export type SessionUpdateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
}
|
||||
export type SessionUpdateOutput = void
|
||||
@@ -517,20 +516,6 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.metadata.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?:
|
||||
| {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
|
||||
}
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly metadata: Session.Metadata }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
|
||||
@@ -453,7 +453,7 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
|
||||
preserveEffect<SessionUpdateOutput>()(
|
||||
raw["session.update"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
payload: { title: input["title"], permissions: input["permissions"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -662,7 +662,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
body: { title: input["title"], permissions: input["permissions"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
|
||||
@@ -456,10 +456,6 @@ export type V2EventServerConnected = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
|
||||
|
||||
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
|
||||
|
||||
export type ProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
@@ -1221,16 +1217,6 @@ export type SessionMoved = {
|
||||
|
||||
export type SessionInboxMovePayload1 = { location: LocationRef; projectID: string; subpath?: string }
|
||||
|
||||
export type SessionMetadataUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.metadata.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; metadata: SessionMetadata }
|
||||
}
|
||||
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1670,19 +1656,19 @@ export type SessionInboxMove = {
|
||||
payload: SessionInboxMovePayload
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
@@ -1912,7 +1898,7 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ModelSettings
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
@@ -2121,13 +2107,13 @@ export type ConfigEntry =
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ConfigModelSettings
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: ConfigModelSettings
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
@@ -2318,7 +2304,6 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
@@ -2381,7 +2366,6 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
@@ -3958,21 +3942,12 @@ export type SessionUpdateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly title?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
}["title"]
|
||||
readonly metadata?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
|
||||
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
|
||||
|
||||
## How it differs from JavaScript
|
||||
|
||||
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
|
||||
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
|
||||
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
|
||||
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
|
||||
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
|
||||
@@ -94,19 +94,11 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Extension.make`
|
||||
### `Values`
|
||||
|
||||
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
|
||||
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
|
||||
|
||||
```ts
|
||||
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
|
||||
const runtime = CodeMode.make({ tools, extensions: [web] })
|
||||
```
|
||||
|
||||
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
|
||||
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
|
||||
extension throws at `CodeMode.make`.
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Source and execution model
|
||||
|
||||
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
|
||||
TypeScript-only syntax is rejected rather than stripped before execution.
|
||||
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
|
||||
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
@@ -28,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
|
||||
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
|
||||
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
@@ -40,12 +41,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
|
||||
rest parameter are accepted unless the program itself begins with `"use strict"`.
|
||||
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
|
||||
default in a `for...of` head and Unicode-escaped keywords.
|
||||
|
||||
## Values and literals
|
||||
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
|
||||
synchronous iterators, and synchronous generators.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Template literals with interpolation.
|
||||
@@ -92,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `if`/`else` and conditional expressions.
|
||||
- [x] `switch`, including default clauses and fallthrough.
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
|
||||
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
@@ -124,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
because `includes` is called without a string `this`.
|
||||
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
|
||||
like JS.
|
||||
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
|
||||
arrow function.
|
||||
@@ -176,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||
`await` still defers its continuation one reaction turn.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
|
||||
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
|
||||
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
|
||||
non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
@@ -448,13 +451,7 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
|
||||
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
|
||||
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
|
||||
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
|
||||
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
|
||||
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
|
||||
iteration is live and sorted by name, with `set-cookie` values kept apart.
|
||||
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
|
||||
- [ ] `Request`, `Response`, and `Blob`.
|
||||
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -464,8 +461,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
|
||||
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
|
||||
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
@@ -497,8 +494,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
`catch`.
|
||||
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
|
||||
short orientation to the supported subset; this matrix is the full reference.
|
||||
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -19,6 +19,12 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#transpile": {
|
||||
"workerd": "./src/interpreter/transpile.workerd.ts",
|
||||
"default": "./src/interpreter/transpile.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -26,7 +32,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
imports?: Record<string, Record<string, string>>
|
||||
imports: Record<string, Record<string, string>>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
@@ -41,14 +41,12 @@ try {
|
||||
]
|
||||
}),
|
||||
)
|
||||
if (pkg.imports) {
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
}
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
record,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./interpreter/objects.js"
|
||||
import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
@@ -70,7 +69,6 @@ const walk = <R>(
|
||||
)
|
||||
}
|
||||
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
|
||||
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
|
||||
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
|
||||
stack.add(target)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { parse, type Program } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { toBoundary } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { createBuiltins } from "./intrinsics.js"
|
||||
import { PendingThrow } from "./model.js"
|
||||
import { Pending } from "./promises.js"
|
||||
import { Interpreter } from "./interpreter.js"
|
||||
|
||||
@@ -106,7 +110,16 @@ export const executeProgram = <R>(
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): Program => {
|
||||
return parse(code, {
|
||||
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
|
||||
|
||||
if (transpiled.error !== undefined) {
|
||||
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
return parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
@@ -53,7 +52,6 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
if (value instanceof URLObj) return new URL(value.url.href)
|
||||
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
|
||||
if (value instanceof HeadersObj) return new Headers(value.headers)
|
||||
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
|
||||
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
|
||||
if (value instanceof SetObj) return new Set([...value.set].map(next))
|
||||
@@ -127,7 +125,6 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
|
||||
@@ -11,7 +11,6 @@ import { objectGlobal } from "../stdlib/object.js"
|
||||
import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { headersGlobal } from "../stdlib/headers.js"
|
||||
import { coercion } from "../stdlib/value.js"
|
||||
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
@@ -81,7 +80,6 @@ const table: Record<string, Factory> = {
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
|
||||
TextEncoder: (ctx) => textEncoderGlobal(ctx),
|
||||
TextDecoder: (ctx) => textDecoderGlobal(ctx),
|
||||
|
||||
@@ -84,7 +84,6 @@ import {
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
@@ -654,7 +653,7 @@ class Frame<R> {
|
||||
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
|
||||
if (iterator === undefined && cursor === undefined) {
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -757,11 +756,9 @@ class Frame<R> {
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.entries()
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
const proto = this.ctx.builtins.Array
|
||||
return Effect.succeed({
|
||||
@@ -1851,7 +1848,6 @@ class Frame<R> {
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
) {
|
||||
const cursor = yield* self.iterate(value, node)
|
||||
|
||||
@@ -29,7 +29,6 @@ const builtins = [
|
||||
"Set",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
"Uint8Array",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
@@ -81,7 +80,6 @@ export const createBuiltins = (): Builtins => {
|
||||
Set: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
Uint8Array: plain(),
|
||||
TextEncoder: plain(),
|
||||
TextDecoder: plain(),
|
||||
|
||||
@@ -156,15 +156,6 @@ export class URLSearchParamsObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadersObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly headers: Headers,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class URLObj extends Obj {
|
||||
readonly searchParams: URLSearchParamsObj
|
||||
constructor(
|
||||
@@ -190,14 +181,13 @@ export class Bytes extends Obj {
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
|
||||
value instanceof DateObj ||
|
||||
value instanceof RegExpObj ||
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
|
||||
const MAX_ARRAY_INDEX = 4_294_967_295
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
@@ -86,7 +85,6 @@ export const describeValue = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "a Set"
|
||||
if (value instanceof URLObj) return "a URL"
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof HeadersObj) return "a Headers"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Full TypeScript transpilation on node/bun runtimes.
|
||||
export const transpile = (source: string): TranspileResult => {
|
||||
const transpiled = transpileModule(source, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
if (diagnostic) {
|
||||
return {
|
||||
outputText: transpiled.outputText,
|
||||
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
}
|
||||
}
|
||||
return { outputText: transpiled.outputText }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// workerd profile: the typescript compiler is ~11 MiB and probes node
|
||||
// internals at module init, so codemode programs are passed through
|
||||
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
|
||||
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
|
||||
// error from the interpreter instead of a transpile diagnostic.
|
||||
export const transpile = (source: string): TranspileResult => ({ outputText: source })
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -67,7 +66,6 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (value instanceof RegExpObj) return coerceToString(value)
|
||||
if (value instanceof URLObj) return coerceToString(value)
|
||||
if (value instanceof URLSearchParamsObj) return coerceToString(value)
|
||||
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
|
||||
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
|
||||
import { applyCollectionCallback } from "../interpreter/callback.js"
|
||||
import { isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
import { readPairs } from "./url.js"
|
||||
|
||||
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
|
||||
const attempt = <T>(run: () => T): T => {
|
||||
try {
|
||||
return run()
|
||||
} catch (error) {
|
||||
throw typeError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
|
||||
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
|
||||
if (init === undefined) return Effect.succeed(wrap(new Headers()))
|
||||
return Effect.gen(function* () {
|
||||
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
|
||||
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
|
||||
if (!(init instanceof Obj) || isRuntimeReference(init)) {
|
||||
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
|
||||
}
|
||||
return wrap(
|
||||
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.Headers
|
||||
const headers = constructor<R>(builtins, proto, {
|
||||
name: "Headers",
|
||||
call: requiresNew("Headers"),
|
||||
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
|
||||
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
|
||||
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
|
||||
}
|
||||
methods(builtins, proto, [
|
||||
[
|
||||
"append",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("append", args, 2)
|
||||
const target = self(thisValue, "append").headers
|
||||
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"delete",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("delete", args, 1)
|
||||
const target = self(thisValue, "delete").headers
|
||||
return attempt(() => target.delete(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("get", args, 1)
|
||||
const target = self(thisValue, "get").headers
|
||||
return attempt(() => target.get(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
|
||||
[
|
||||
"has",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("has", args, 1)
|
||||
const target = self(thisValue, "has").headers
|
||||
return attempt(() => target.has(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("set", args, 2)
|
||||
const target = self(thisValue, "set").headers
|
||||
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("forEach", args, 1)
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
return headers
|
||||
}
|
||||
@@ -107,10 +107,12 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return url
|
||||
}
|
||||
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(value)
|
||||
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
const items: Array<string> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -124,29 +126,6 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
|
||||
* WebIDL, the whole sequence is converted before any pair's length is checked.
|
||||
*/
|
||||
export const readPairs = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
label: string,
|
||||
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor === undefined) return undefined
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
return pairs as Array<[string, string]>
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
|
||||
}
|
||||
})
|
||||
|
||||
const constructURLSearchParams = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
@@ -160,8 +139,20 @@ const constructURLSearchParams = <R>(
|
||||
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
|
||||
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor !== undefined) {
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
|
||||
}
|
||||
}
|
||||
if (isRuntimeReference(init)) {
|
||||
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
@@ -29,7 +28,6 @@ export const coerceToString = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "[object Set]"
|
||||
if (value instanceof URLObj) return value.url.href
|
||||
if (value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return "[object Headers]"
|
||||
if (value instanceof Bytes) return value.bytes.join(",")
|
||||
if (value instanceof ErrorObj) {
|
||||
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
|
||||
|
||||
@@ -15,12 +15,6 @@ const error = async (code: string) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("source syntax", () => {
|
||||
test("rejects TypeScript-only syntax", async () => {
|
||||
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
@@ -107,7 +101,7 @@ describe("host errors escaping built-ins", () => {
|
||||
test("report the location of the call that raised them", async () => {
|
||||
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
|
||||
})
|
||||
|
||||
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
|
||||
@@ -115,13 +109,13 @@ describe("host errors escaping built-ins", () => {
|
||||
})
|
||||
|
||||
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
|
||||
"(line 1, col 10)",
|
||||
"(line 1, col 14)",
|
||||
)
|
||||
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
|
||||
"(line 1, col 8)",
|
||||
"(line 2, col 5)",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -134,7 +128,7 @@ describe("host errors escaping built-ins", () => {
|
||||
|
||||
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
|
||||
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,7 +147,7 @@ describe("call depth", () => {
|
||||
test("uncaught overflow reports the call that overflowed", async () => {
|
||||
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
|
||||
})
|
||||
|
||||
test("the limit is 10000 nested calls", async () => {
|
||||
|
||||
@@ -128,34 +128,6 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
|
||||
})
|
||||
|
||||
test("Headers cross as copies in both directions", async () => {
|
||||
const stored = new Headers({ "X-A": "1" })
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "http",
|
||||
globals: {
|
||||
headers: () => stored,
|
||||
keep: (value: Headers) => {
|
||||
held.push(value)
|
||||
return value
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
held.length = 0
|
||||
expect(
|
||||
await value(
|
||||
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
|
||||
target,
|
||||
),
|
||||
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
|
||||
expect(stored.get("x-a")).toBe("1")
|
||||
expect(held[0]).toBeInstanceOf(Headers)
|
||||
expect((held[0] as Headers).get("x-a")).toBe("2")
|
||||
})
|
||||
|
||||
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
|
||||
const stored = new Uint8Array([1, 2, 3])
|
||||
const target = CodeMode.make({
|
||||
|
||||
@@ -635,154 +635,6 @@ describe("URL and URI helpers", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Headers", () => {
|
||||
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
|
||||
headers.append("Accept", "text/html")
|
||||
headers.append("accept", "application/json")
|
||||
headers.set("x-count", "2")
|
||||
headers.delete("x-null")
|
||||
const copy = new Headers(headers)
|
||||
copy.set("content-type", "text/html")
|
||||
return {
|
||||
get: headers.get("content-type"),
|
||||
missing: headers.get("x-missing"),
|
||||
combined: headers.get("ACCEPT"),
|
||||
has: [headers.has("Accept"), headers.has("x-null")],
|
||||
count: headers.get("x-count"),
|
||||
copied: [headers.get("content-type"), copy.get("content-type")],
|
||||
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
|
||||
map: [...new Headers(new Map([["k", "v"]]))],
|
||||
keys: headers.keys(),
|
||||
values: headers.values(),
|
||||
entries: headers.entries(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
get: "text/plain",
|
||||
missing: null,
|
||||
combined: "text/html, application/json",
|
||||
has: [true, false],
|
||||
count: "2",
|
||||
copied: ["text/plain", "text/html"],
|
||||
pairs: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
],
|
||||
map: [["k", "v"]],
|
||||
keys: ["accept", "content-type", "x-count"],
|
||||
values: ["text/html, application/json", "text/plain", "2"],
|
||||
entries: [
|
||||
["accept", "text/html, application/json"],
|
||||
["content-type", "text/plain"],
|
||||
["x-count", "2"],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ b: "2", a: "1" })
|
||||
headers.append("Set-Cookie", "x=1")
|
||||
headers.append("set-cookie", "y=2")
|
||||
const seen = []
|
||||
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
|
||||
const [first] = headers
|
||||
function* pairs() { yield* headers }
|
||||
return {
|
||||
seen,
|
||||
first,
|
||||
spread: [...headers],
|
||||
from: Array.from(headers).length,
|
||||
generator: [...pairs()].length,
|
||||
object: Object.fromEntries(headers),
|
||||
cookies: headers.getSetCookie(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
|
||||
first: ["a", "1"],
|
||||
spread: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["set-cookie", "x=1"],
|
||||
["set-cookie", "y=2"],
|
||||
],
|
||||
from: 4,
|
||||
generator: 4,
|
||||
object: { a: "1", b: "2", "set-cookie": "y=2" },
|
||||
cookies: ["x=1", "y=2"],
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
|
||||
const result = await run(`
|
||||
const headers = new Headers({ "X-A": "1", b: "2" })
|
||||
console.log(headers)
|
||||
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
|
||||
`)
|
||||
expect(result.ok && result.value).toEqual({
|
||||
headers: { b: "2", "x-a": "1" },
|
||||
json: '{"headers":{"b":"2","x-a":"1"}}',
|
||||
text: "[object Headers]",
|
||||
type: "object",
|
||||
is: true,
|
||||
})
|
||||
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
|
||||
})
|
||||
|
||||
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function message(run) {
|
||||
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
|
||||
}
|
||||
const headers = new Headers()
|
||||
return [
|
||||
message(() => Headers()),
|
||||
message(() => new Headers(null)),
|
||||
message(() => new Headers(1)),
|
||||
message(() => new Headers("a=1")),
|
||||
message(() => new Headers(new Date())),
|
||||
message(() => new Headers(() => 1)),
|
||||
message(() => new Headers([["name"]])),
|
||||
message(() => new Headers([["a", "b", "c"]])),
|
||||
message(() => new Headers({ "bad name": "x" })),
|
||||
message(() => new Headers({ name: "bad\u0000value" })),
|
||||
message(() => headers.get("invalid\u0100")),
|
||||
message(() => headers.has({})),
|
||||
message(() => headers.set("a", "invalid\u0100")),
|
||||
message(() => headers.append("a")),
|
||||
message(() => headers.forEach()),
|
||||
message(() => headers.forEach(1)),
|
||||
message(() => { const get = headers.get; return get("a") }),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"Constructor Headers requires 'new'.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
expect.stringContaining("bad name"),
|
||||
expect.stringContaining("invalid value"),
|
||||
expect.stringContaining("Invalid header name"),
|
||||
expect.stringContaining("[object Object]"),
|
||||
expect.stringContaining("invalid value"),
|
||||
"Headers.append requires 2 arguments.",
|
||||
"Headers.forEach requires 1 argument.",
|
||||
"Headers.forEach expects a function callback.",
|
||||
"Headers.prototype.get called on incompatible receiver undefined.",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Map", () => {
|
||||
test("get/set/has/size with chaining", async () => {
|
||||
expect(
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
|
||||
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
|
||||
* arrays), or a custom iterator on a Headers instance are left out.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -169,225 +166,3 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
|
||||
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
|
||||
const testharness = `
|
||||
const failures = []
|
||||
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
|
||||
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
|
||||
function assert_true(actual, message) { assert_equals(actual, true, message) }
|
||||
function assert_false(actual, message) { assert_equals(actual, false, message) }
|
||||
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
|
||||
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
|
||||
function assert_unreached() { throw new Error("unreachable") }
|
||||
`
|
||||
|
||||
describe("Headers WPT parity (fetch/api/headers)", () => {
|
||||
test("headers-basic.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { new Headers() }, "Create headers from no parameter")
|
||||
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
|
||||
test(function() { new Headers({}) }, "Create headers from empty object")
|
||||
var parameters = [null, 1]
|
||||
parameters.forEach(function(parameter) {
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
|
||||
})
|
||||
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
|
||||
var headerSeq = []
|
||||
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeq)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
|
||||
}, "Create headers with sequence")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with record")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
var headers2 = new Headers(headers)
|
||||
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with existing headers")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.append(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check append method")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.set(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check set method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
|
||||
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
|
||||
}, "Check has method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) {
|
||||
assert_true(headers.has(name), "headers have a header: " + name)
|
||||
headers.delete(name)
|
||||
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
|
||||
}
|
||||
}, "Check delete method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
|
||||
}, "Check get method")
|
||||
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
|
||||
var sortedHeaderDict = {}
|
||||
var headerValues = []
|
||||
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
|
||||
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
|
||||
headerValues.push(headerEntriesDict[value])
|
||||
return value.toLowerCase()
|
||||
}).sort()
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.keys(), sortedHeaderKeys)
|
||||
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
|
||||
}, "Check keys method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
|
||||
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
|
||||
}, "Check values method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
|
||||
}, "Check entries method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
}, "Check Symbol.iterator method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var index = 0
|
||||
headers.forEach(function(value, key, container) {
|
||||
assert_equals(headers, container)
|
||||
assert_equals(key, sortedHeaderKeys[index])
|
||||
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
|
||||
index++
|
||||
})
|
||||
assert_equals(index, sortedHeaderKeys.length)
|
||||
}, "Check forEach method")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
headers.delete("foo")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz"])
|
||||
assert_array_equals(actualValues, ["0", "1"])
|
||||
}, "Iteration skips elements removed while iterating")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.delete("bar")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "3"])
|
||||
}, "Removing elements already iterated over causes an element to be skipped during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("X-yZ", "4")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
|
||||
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
|
||||
}, "Appending a value pair during iteration causes it to be reached during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("abc", "-1")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
|
||||
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-errors.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
|
||||
var badNames = ["invalid\u0100", {}]
|
||||
var badValues = ["invalid\u0100"]
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
|
||||
})
|
||||
test(function() {
|
||||
var headers = new Headers([["name", "value"]])
|
||||
assert_throws_js(TypeError, function() { headers.forEach() })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(1) })
|
||||
}, "Headers forEach throws if argument is not callable")
|
||||
test(function() {
|
||||
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
|
||||
var counter = 0
|
||||
try {
|
||||
headers.forEach(function(value, name) {
|
||||
counter++
|
||||
if (name == "name2") throw "error"
|
||||
})
|
||||
} catch (e) {
|
||||
assert_equals(counter, 2)
|
||||
assert_equals(e, "error")
|
||||
return
|
||||
}
|
||||
assert_unreached()
|
||||
}, "Headers forEach loop should stop if callback is throwing exception")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
|
||||
import { ProviderShared } from "@opencode/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { makeParser } from "effect/unstable/encoding/Sse"
|
||||
import type { ID, RuntimeInfo } from "./model.js"
|
||||
import type { ID, Info } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: RuntimeInfo
|
||||
readonly model: Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: RuntimeInfo
|
||||
readonly model: Info
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: RuntimeInfo, pkg: string) {
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.canonical ?? model.providerID,
|
||||
@@ -182,8 +182,8 @@ export interface Interface {
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
|
||||
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
|
||||
@@ -302,7 +302,7 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const providerID = info.canonical ?? info.providerID
|
||||
@@ -399,7 +399,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
|
||||
function mapBodyToProviderOptions(model: Info, packageName: string) {
|
||||
const settings = requestSettings(model.settings)
|
||||
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
|
||||
const forceReasoning =
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as McpInstructions from "./instructions.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { Mcp } from "./index.js"
|
||||
@@ -54,8 +55,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Lists server instructions reachable under the given ruleset; callers pass the merged agent and Session permissions. */
|
||||
readonly load: (permissions: Permission.Ruleset) => Effect.Effect<Instructions.List>
|
||||
readonly load: (agent: Agent.Selection) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpInstructions") {}
|
||||
@@ -66,7 +66,9 @@ export const layer = Layer.effect(
|
||||
const mcp = yield* Mcp.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("McpInstructions.load")(function* (permissions) {
|
||||
load: Effect.fn("McpInstructions.load")(function* (selection) {
|
||||
const agent = selection.info
|
||||
if (!agent) return Instructions.empty
|
||||
const source = (value: ReadonlyArray<Summary> | Instructions.Removed) =>
|
||||
Instructions.make<ReadonlyArray<Summary>>({
|
||||
key: Instructions.Key.make("core/mcp-guidance"),
|
||||
@@ -81,8 +83,8 @@ export const layer = Layer.effect(
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const canExecute = Permission.evaluate("execute", "*", permissions).effect !== "deny"
|
||||
// Instructions are useful only when this Session can reach at least one server tool.
|
||||
const canExecute = Permission.evaluate("execute", "*", agent.permissions).effect !== "deny"
|
||||
// Instructions are useful only when this agent can reach at least one server tool.
|
||||
const visible = instructions
|
||||
.flatMap((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
@@ -91,7 +93,7 @@ export const layer = Layer.effect(
|
||||
if (
|
||||
!owned.some(
|
||||
(tool) =>
|
||||
Permission.evaluate(McpTool.name(tool.server, tool.name), "*", permissions).effect !== "deny",
|
||||
Permission.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
return []
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AISDK } from "./aisdk.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
|
||||
import type { RuntimeInfo } from "./model.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
@@ -118,9 +117,9 @@ export interface Resolved {
|
||||
readonly cost: Info["cost"]
|
||||
/** Catalog token limits used by Core for context management. */
|
||||
readonly limit: Info["limit"]
|
||||
/** Model policy overrides the provider policy; omitted means summary compaction. */
|
||||
/** Model policy overrides the provider policy; omitted means local compaction. */
|
||||
readonly compaction?: Provider.Compaction
|
||||
/** Provider transport policy; omitted means HTTP. */
|
||||
/** Model transport overrides the provider transport; omitted means HTTP. */
|
||||
readonly transport?: Provider.Transport
|
||||
}
|
||||
|
||||
@@ -150,7 +149,7 @@ export const withVariant = (
|
||||
variant
|
||||
? {
|
||||
...model,
|
||||
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
|
||||
settings: Provider.mergeOverlay(model.settings, variant.settings),
|
||||
headers: Provider.mergeHeaders(model.headers, variant.headers),
|
||||
body: Provider.mergeOverlay(model.body, variant.body),
|
||||
}
|
||||
@@ -160,11 +159,11 @@ export const withVariant = (
|
||||
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
||||
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<
|
||||
@@ -192,7 +191,7 @@ export const fromCatalogModel = (
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
@@ -249,7 +248,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
})
|
||||
})
|
||||
|
||||
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return {
|
||||
...model,
|
||||
@@ -261,7 +260,7 @@ function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value |
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
resolved: LanguageModel,
|
||||
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
|
||||
const baseURL = resolved.route.endpoint.baseURL
|
||||
@@ -271,7 +270,7 @@ function validateProviderVariables(
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
|
||||
const baseURL = settings.baseURL
|
||||
@@ -281,14 +280,14 @@ function prepareProviderSettings(
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
|
||||
}
|
||||
|
||||
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
|
||||
function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
|
||||
if (variables.size === 0) return
|
||||
return new UnresolvedProviderVariablesError({
|
||||
@@ -311,14 +310,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const unsupported = (model: RuntimeInfo) =>
|
||||
const unsupported = (model: Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
|
||||
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
|
||||
new ModelInitializationError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
@@ -360,11 +359,7 @@ export const layer = Layer.effect(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const selectedVariant = yield* withVariant(selected, variant)
|
||||
const runtimeInfo: RuntimeInfo = {
|
||||
...selectedVariant,
|
||||
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
|
||||
}
|
||||
const runtimeInfo = yield* withVariant(selected, variant)
|
||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
@@ -387,7 +382,7 @@ export const layer = Layer.effect(
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: runtimeInfo.settings?.compaction,
|
||||
transport: provider?.settings?.transport,
|
||||
transport: runtimeInfo.settings?.transport,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
@@ -411,7 +406,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function hasConfiguredAuth(model: RuntimeInfo) {
|
||||
function hasConfiguredAuth(model: Info) {
|
||||
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||
(value) => typeof value === "string" && value !== "",
|
||||
)
|
||||
|
||||
@@ -36,9 +36,6 @@ export type Ref = typeof Ref.Type
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
/** Effective provider and model settings used only while constructing a runtime model. */
|
||||
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
|
||||
|
||||
export type MutableInfo = DeepMutable<Info>
|
||||
|
||||
export { Event } from "@opencode/schema/model"
|
||||
@@ -191,10 +188,7 @@ const layer = Layer.effect(
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
settings: Provider.mergeOverlay(
|
||||
Provider.modelSettings(provider?.settings),
|
||||
Provider.modelSettings(model.settings),
|
||||
),
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
} satisfies Info
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -7,6 +7,7 @@ import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { which } from "../../util/which.js"
|
||||
@@ -139,18 +140,16 @@ export const AzurePlugin = define({
|
||||
)
|
||||
continue
|
||||
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
|
||||
const websocket = responsesWebSocketCapable(item.provider)
|
||||
if (!resourceName && !websocket) continue
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(resourceName === undefined ? {} : { resourceName }),
|
||||
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
|
||||
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
if (resourceName)
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
resourceName,
|
||||
...(typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
@@ -168,6 +167,10 @@ export const AzurePlugin = define({
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: item.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -239,9 +242,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
|
||||
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
|
||||
}
|
||||
|
||||
function responsesWebSocketCapable(provider: Provider.Info) {
|
||||
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = provider.settings
|
||||
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
|
||||
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = Provider.mergeOverlay(provider.settings, model.settings)
|
||||
if (settings?.useDeploymentBasedUrls === true) return false
|
||||
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
|
||||
if (typeof settings?.baseURL !== "string") return true
|
||||
|
||||
@@ -254,13 +254,10 @@ export const OpenAIPlugin = define({
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
const item = providers.get(Provider.ID.openai)
|
||||
if (!item) return
|
||||
const account = chatgpt?.metadata?.accountID
|
||||
if (!chatgpt) return
|
||||
const account = chatgpt.metadata?.accountID
|
||||
providers.update(item.provider.id, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
...(chatgpt ? { baseURL: codexBaseURL } : {}),
|
||||
})
|
||||
if (!chatgpt) return
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||
originator: "opencode",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
@@ -273,6 +270,9 @@ export const OpenAIPlugin = define({
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -95,13 +95,14 @@ export const XAIPlugin = define({
|
||||
editor.method.update(device(ctx.app))
|
||||
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
if (!providers.get(providerID)) return
|
||||
providers.update(providerID, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -134,16 +134,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
|
||||
|
||||
/** opencode settings consumed in Core; native packages never receive them. */
|
||||
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
|
||||
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
|
||||
|
||||
export function nativeSettings(settings: Settings): Settings {
|
||||
return Struct.omit(settings, CORE_KEYS)
|
||||
}
|
||||
|
||||
export function modelSettings(settings: Settings | undefined) {
|
||||
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
|
||||
}
|
||||
|
||||
export function mergeOverlay(
|
||||
base: Readonly<Record<string, unknown>> | undefined,
|
||||
overlay: Readonly<Record<string, unknown>> | undefined,
|
||||
|
||||
@@ -170,10 +170,6 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly setMetadata: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
metadata: SessionSchema.Metadata
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly setPermissions: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
permissions: Permission.Ruleset
|
||||
@@ -421,7 +417,6 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
setMetadata: (input) => sessions.forSession(input.sessionID).setMetadata(input),
|
||||
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
|
||||
move: moves.move,
|
||||
compact: (input) => sessions.forSession(input.sessionID).compact(input),
|
||||
|
||||
@@ -127,16 +127,14 @@ const layer = Layer.effect(
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
// Session permissions narrow discovery the same way they narrow the tool snapshot.
|
||||
const permissions = Permission.merge(agent.info.permissions, session.permissions ?? [])
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(permissions),
|
||||
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(permissions),
|
||||
skills: skillInstructions.load(agent),
|
||||
references: referenceInstructions.load(),
|
||||
mcp: mcpInstructions.load(permissions),
|
||||
mcp: mcpInstructions.load(agent),
|
||||
entries: entries.load(sessionID),
|
||||
},
|
||||
{ concurrency: "unbounded" },
|
||||
|
||||
@@ -131,7 +131,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
}),
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.metadata.updated": () => Effect.void,
|
||||
"session.permissions": () => Effect.void,
|
||||
"session.deleted": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
|
||||
@@ -573,14 +573,6 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.MetadataUpdated, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ metadata: event.data.metadata, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Permissions, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
|
||||
@@ -73,13 +73,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
|
||||
})
|
||||
const setMetadata = Effect.fn("Session.setMetadata")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { metadata: SessionSchema.Metadata },
|
||||
) {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.MetadataUpdated, { sessionID, metadata: input.metadata })
|
||||
})
|
||||
const setPermissions = Effect.fn("Session.setPermissions")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { permissions: Permission.Ruleset },
|
||||
@@ -349,7 +342,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
@@ -373,7 +365,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const message = operations.message.bind(undefined, sessionID)
|
||||
const view = operations.view.bind(undefined, sessionID)
|
||||
const rename = operations.rename.bind(undefined, sessionID)
|
||||
const setMetadata = operations.setMetadata.bind(undefined, sessionID)
|
||||
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
|
||||
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
|
||||
const switchModel = operations.switchModel.bind(undefined, sessionID)
|
||||
@@ -400,7 +391,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setMetadata,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import type { FSUtil } from "@opencode/util/fs-util"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Permission } from "./permission.js"
|
||||
@@ -30,8 +31,8 @@ export type Name = Skill.Name
|
||||
|
||||
export { Event } from "@opencode/schema/skill"
|
||||
|
||||
export const available = (skills: ReadonlyArray<Info>, permissions: Permission.Ruleset) =>
|
||||
skills.filter((skill) => Permission.evaluate("skill", skill.id, permissions).effect !== "deny")
|
||||
export const available = (skills: ReadonlyArray<Info>, agent: Agent.Info) =>
|
||||
skills.filter((skill) => Permission.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
|
||||
|
||||
export const toModelOutput = (skill: Info, files: ReadonlyArray<string>) => {
|
||||
const directory = path.dirname(skill.path)
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SkillInstructions from "./instructions.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Skill } from "../skill.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
|
||||
@@ -58,8 +58,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Lists skills the given ruleset does not deny; callers pass the merged agent and Session permissions. */
|
||||
readonly load: (permissions: Permission.Ruleset) => Effect.Effect<Instructions.List>
|
||||
readonly load: (agent: Agent.Selection) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SkillInstructions") {}
|
||||
@@ -70,8 +69,10 @@ const layer = Layer.effect(
|
||||
const skills = yield* Skill.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("SkillInstructions.load")(function* (permissions) {
|
||||
const available = Skill.available(yield* skills.list(), permissions)
|
||||
load: Effect.fn("SkillInstructions.load")(function* (selection) {
|
||||
const agent = selection.info
|
||||
if (!agent) return Instructions.empty
|
||||
const available = Skill.available(yield* skills.list(), agent)
|
||||
.flatMap((skill) =>
|
||||
skill.description === undefined || skill.autoinvoke === false
|
||||
? []
|
||||
|
||||
@@ -27,15 +27,14 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
|
||||
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
|
||||
...Model.Info.make({
|
||||
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
|
||||
modelID: Model.ID.make("api-model"),
|
||||
package: Provider.aisdk(packageName),
|
||||
settings,
|
||||
limit: { context: 100, output: 20 },
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
})
|
||||
|
||||
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
|
||||
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps settings scoped while resolving request overlay merges", () =>
|
||||
it.effect("resolves provider and model overlay merges", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
@@ -533,7 +533,6 @@ describe("Provider and Model", () => {
|
||||
})
|
||||
|
||||
const model = required(yield* models.get(providerID, modelID))
|
||||
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
|
||||
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
|
||||
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
|
||||
|
||||
@@ -37,7 +37,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
const providers = yield* Provider.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
@@ -64,7 +63,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect(native.settings?.compaction).toEqual({ type: "native" })
|
||||
expect(local.settings?.compaction).toEqual({ type: "summary" })
|
||||
expect(defaultModel.settings?.compaction).toBeUndefined()
|
||||
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.compaction).toEqual({ type: "native" })
|
||||
yield* ModelResolver.fromCatalogModel(native)
|
||||
yield* ModelResolver.fromCatalogModel(local)
|
||||
yield* ModelResolver.fromCatalogModel(defaultModel)
|
||||
@@ -75,9 +73,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the provider websocket policy out of model settings", () =>
|
||||
it.effect("inherits the provider websocket policy with model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -86,12 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
|
||||
models: {
|
||||
inherited: {
|
||||
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
|
||||
},
|
||||
},
|
||||
settings: { transport: "http" },
|
||||
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
|
||||
},
|
||||
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
|
||||
},
|
||||
@@ -99,9 +92,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
])
|
||||
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
|
||||
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
|
||||
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
|
||||
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
|
||||
expect(inherited.settings).toEqual({ shared: "provider", model: true })
|
||||
expect(inherited.settings?.transport).toBe("http")
|
||||
expect(override.settings?.transport).toBe("websocket")
|
||||
expect(untouched.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
@@ -112,7 +106,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
]) {
|
||||
it.live(`configured provider transport overrides ${builtin.id} defaults`, () =>
|
||||
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
@@ -128,8 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
editor.models.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* builtin.plugin.effect(host)
|
||||
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -138,16 +131,15 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
[builtin.id]: {
|
||||
settings: { transport: "http" },
|
||||
models: { override: { modelID: builtin.model } },
|
||||
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* providers.get(providerID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBeUndefined()
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { McpInstructions } from "@opencode/core/mcp/instructions"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { McpTool } from "@opencode/core/tool/mcp"
|
||||
import { it } from "./lib/effect"
|
||||
import { readInitial, readUpdate } from "./lib/instructions"
|
||||
|
||||
const build = Agent.ID.make("build")
|
||||
|
||||
const selection = (permissions: Permission.Ruleset = []) => {
|
||||
const info = Agent.Info.make({ ...Agent.Info.default(build), permissions })
|
||||
return { id: info.id, info }
|
||||
}
|
||||
|
||||
const instructions = (server: string, text: string) =>
|
||||
({ server: Mcp.ServerName.make(server), instructions: text }) satisfies Mcp.ServerInstructions
|
||||
({ server: Mcp.ServerName.make(server), instructions: text } satisfies Mcp.ServerInstructions)
|
||||
|
||||
const schema = { type: "object" as const }
|
||||
const tool = (server: string, name = "search") =>
|
||||
@@ -29,10 +38,12 @@ describe("McpInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load([
|
||||
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
|
||||
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
|
||||
])
|
||||
.load(
|
||||
selection([
|
||||
{ action: McpTool.name("alpha", "restricted"), resource: "*", effect: "deny" },
|
||||
{ action: McpTool.name("hidden", "search"), resource: "*", effect: "deny" },
|
||||
]),
|
||||
)
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe(
|
||||
@@ -69,7 +80,7 @@ describe("McpInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe("")
|
||||
@@ -87,7 +98,7 @@ describe("McpInstructions", () => {
|
||||
Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const generation = yield* service
|
||||
.load([{ action: "execute", resource: "*", effect: "deny" }])
|
||||
.load(selection([{ action: "execute", resource: "*", effect: "deny" }]))
|
||||
.pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(generation.text).toBe(
|
||||
@@ -103,14 +114,7 @@ describe("McpInstructions", () => {
|
||||
Effect.provide(
|
||||
layer(
|
||||
() => [instructions("alpha", "Alpha instructions")],
|
||||
() => [
|
||||
{
|
||||
server: Mcp.ServerName.make("alpha"),
|
||||
name: "search",
|
||||
inputSchema: schema,
|
||||
codemode: false,
|
||||
} satisfies Mcp.Tool,
|
||||
],
|
||||
() => [({ server: Mcp.ServerName.make("alpha"), name: "search", inputSchema: schema, codemode: false }) satisfies Mcp.Tool],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -120,10 +124,10 @@ describe("McpInstructions", () => {
|
||||
let tools: Mcp.Tool[] = [tool("alpha")]
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
tools = [{ ...tool("alpha"), codemode: false }]
|
||||
const changed = yield* readUpdate(yield* service.load([]), initialized)
|
||||
const changed = yield* readUpdate(yield* service.load(selection()), initialized)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
@@ -149,10 +153,10 @@ describe("McpInstructions", () => {
|
||||
const tools = [tool("alpha"), tool("beta")]
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* McpInstructions.Service
|
||||
const initialized = yield* service.load([]).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* service.load(selection()).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
catalog = [instructions("alpha", "Alpha instructions"), instructions("beta", "Beta instructions")]
|
||||
const added = yield* readUpdate(yield* service.load([]), initialized)
|
||||
const added = yield* readUpdate(yield* service.load(selection()), initialized)
|
||||
expect(added.text).toBe(
|
||||
[
|
||||
"New MCP server instructions are available in addition to those previously listed:",
|
||||
@@ -164,7 +168,7 @@ describe("McpInstructions", () => {
|
||||
)
|
||||
|
||||
catalog = [instructions("alpha", "Updated alpha"), instructions("beta", "Beta instructions")]
|
||||
const changed = yield* readUpdate(yield* service.load([]), added)
|
||||
const changed = yield* readUpdate(yield* service.load(selection()), added)
|
||||
expect(changed.text).toBe(
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
@@ -182,11 +186,11 @@ describe("McpInstructions", () => {
|
||||
)
|
||||
|
||||
catalog = [instructions("beta", "Beta instructions")]
|
||||
const removed = yield* readUpdate(yield* service.load([]), changed)
|
||||
const removed = yield* readUpdate(yield* service.load(selection()), changed)
|
||||
expect(removed.text).toBe("Instructions for the following MCP servers are no longer available: alpha.")
|
||||
|
||||
catalog = []
|
||||
expect((yield* readUpdate(yield* service.load([]), removed)).text).toBe(
|
||||
expect((yield* readUpdate(yield* service.load(selection()), removed)).text).toBe(
|
||||
"MCP server instructions are no longer available.",
|
||||
)
|
||||
}).pipe(
|
||||
|
||||
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
|
||||
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
@@ -472,8 +472,9 @@ describe("AzurePlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
|
||||
expect(responses.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
const model = required(yield* service.get(Provider.ID.azure, modelID))
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
}
|
||||
|
||||
@@ -205,8 +205,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(model.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(provider.settings?.transport).toBe("websocket")
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
expect(model.settings?.transport).toBe("websocket")
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
expect(direct.baseURL).toBe("https://api.openai.com/v1")
|
||||
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
|
||||
|
||||
@@ -83,10 +83,8 @@ describe("XAIPlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
const provider = yield* providers.get(providerID)
|
||||
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
|
||||
expect(provider?.settings?.transport).toBe("websocket")
|
||||
expect(model?.settings?.transport).toBeUndefined()
|
||||
expect(model?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -42,19 +42,4 @@ describe("Provider", () => {
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("inherits shared and loose settings without provider-only policies", () => {
|
||||
expect(
|
||||
Provider.modelSettings({
|
||||
timeout: 60_000,
|
||||
chunkTimeout: 30_000,
|
||||
transport: "websocket",
|
||||
compaction: { type: "native" },
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
compaction: { type: "native" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Money } from "@opencode/schema/money"
|
||||
import { Shell } from "@opencode/schema/shell"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
@@ -385,26 +385,6 @@ describe("Session.create", () => {
|
||||
|
||||
// Absent stays absent: no empty-object normalization.
|
||||
expect((yield* session.create({ location })).metadata).toBeUndefined()
|
||||
|
||||
const replacement = { thread: "updated", owner: "host" }
|
||||
yield* session.setMetadata({ sessionID: created.id, metadata: replacement })
|
||||
expect((yield* session.get(created.id)).metadata).toEqual(replacement)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, created.id),
|
||||
eq(EventTable.type, Bus.versionedType(SessionEvent.MetadataUpdated.type, 1)),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toMatchObject({ data: { metadata: replacement } })
|
||||
expect(
|
||||
yield* session.setMetadata({ sessionID: Session.ID.create(), metadata: replacement }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(Session.NotFoundError)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -362,27 +362,21 @@ const layer = Layer.unwrap(
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, {
|
||||
load: (permissions) => {
|
||||
// `setup` tags each agent's ruleset with a `skill` rule naming that agent.
|
||||
const baseline = permissions
|
||||
.filter((rule) => rule.action === "skill")
|
||||
.map((rule) => state.skillBaselines.get(Agent.ID.make(rule.resource)))
|
||||
.findLast((text) => text !== undefined)
|
||||
return Effect.succeed(
|
||||
baseline === undefined
|
||||
? Instructions.empty
|
||||
: Instructions.make({
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
state.skillBaselines.has(agent.id)
|
||||
? Instructions.make({
|
||||
key: Instructions.Key.make("test/skill-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.succeed(baseline),
|
||||
read: Effect.succeed(state.skillBaselines.get(agent.id)!),
|
||||
render: {
|
||||
initial: String,
|
||||
changed: (_previous, current) => current,
|
||||
removed: () => "Skill guidance removed",
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
: Instructions.empty,
|
||||
),
|
||||
})
|
||||
const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
load: () => Effect.succeed(Instructions.empty),
|
||||
@@ -532,17 +526,11 @@ const setup = Effect.gen(function* () {
|
||||
})
|
||||
yield* IdentityPlugin.Plugin.effect(pluginHost)
|
||||
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
|
||||
yield* agents.transform((editor) => {
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
})
|
||||
// Skill instructions receive only a ruleset, so tag each agent with a rule naming itself for the mock.
|
||||
for (const id of ["build", "reviewer"]) {
|
||||
editor.update(Agent.ID.make(id), (agent) => {
|
||||
agent.permissions.push({ action: "skill", resource: id, effect: "allow" })
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("Skill", () => {
|
||||
}),
|
||||
)
|
||||
const agent = yield* agents.get(Agent.ID.make("reviewer"))
|
||||
expect(Skill.available([info("deploy", "Deploy")], agent!.permissions)).toEqual([])
|
||||
expect(Skill.available([info("deploy", "Deploy")], agent!)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("SkillInstructions", () => {
|
||||
let skills = [hidden, denied, manual, effect]
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
@@ -74,7 +74,7 @@ describe("SkillInstructions", () => {
|
||||
skills = []
|
||||
expect(
|
||||
yield* instructions
|
||||
.load(agent.permissions)
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({ text: "Skill guidance is no longer available. Do not use any previously listed skill." })
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
@@ -92,11 +92,11 @@ describe("SkillInstructions", () => {
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
skills = [effect, debugging]
|
||||
const added = yield* instructions
|
||||
.load(agent.permissions)
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized)))
|
||||
expect(added.text).toBe(
|
||||
[
|
||||
@@ -111,7 +111,7 @@ describe("SkillInstructions", () => {
|
||||
|
||||
skills = [debugging]
|
||||
const removed = yield* instructions
|
||||
.load(agent.permissions)
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, added)))
|
||||
expect(removed.text).toBe("The following skill IDs are no longer available and must not be used: effect.")
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
@@ -122,12 +122,12 @@ describe("SkillInstructions", () => {
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
const initialized = yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
|
||||
skills = [Skill.Info.make({ ...effect, description: "Build applications with Effect v4" })]
|
||||
expect(
|
||||
yield* instructions
|
||||
.load(agent.permissions)
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
text: expect.stringContaining(
|
||||
@@ -144,7 +144,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
@@ -158,7 +158,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("SkillInstructions", () => {
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect(
|
||||
(yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text,
|
||||
(yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text,
|
||||
).toContain("<name>Effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -189,7 +189,7 @@ describe("SkillInstructions", () => {
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* SkillInstructions.Service
|
||||
expect((yield* instructions.load(agent.permissions).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
expect((yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))).text).toBe("")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// bun run bench:startup -- [--exe <path>] [--compare <path>] [--runs 5] [--warmup 1] [--service warm|cold]
|
||||
// [--fresh] [--offline] [--seed <userData dir>] [--profile-main] [--profile-renderer]
|
||||
// [--trace] [--out <dir>] [--home <dir>] [--window-at x,y]
|
||||
// [--trace] [--out <dir>] [--home <dir>]
|
||||
//
|
||||
// The app runs in an isolated home directory (its own %APPDATA%, XDG dirs, OpenCode DB, config and
|
||||
// service registration) with the developer's OPENCODE_* / OTEL_* environment stripped, so it never
|
||||
@@ -21,15 +21,7 @@
|
||||
// main-process CPU profile from the first statement (via --inspect-brk) on the first run,
|
||||
// `--profile-renderer` records the renderer main thread from the moment its debug target appears,
|
||||
// and `--trace` records Chromium's startup trace on the last run. Raw samples are written as JSON.
|
||||
//
|
||||
// What is on screen is sampled from the screen itself (Windows): a helper pins the window topmost
|
||||
// without activating it the moment it exists, then records when the window's pixels first differ
|
||||
// from the background colour (`screenPainted`) and when they stop changing (`screenSettled`).
|
||||
// Renderer paint timing alone is not enough: Chromium stops painting an occluded window, and a
|
||||
// splash or a fade reads as "painted" long before the interface is on screen. `--window-at` puts
|
||||
// the window somewhere the developer's foreground window does not cover. BENCH_SCREEN_DUMP=<dir>
|
||||
// also saves every sample as PNG, BENCH_EXTRA_ARGS passes extra Chromium switches to the app.
|
||||
import { execFileSync, spawn } from "node:child_process"
|
||||
import { spawn } from "node:child_process"
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
|
||||
import { createServer } from "node:net"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -53,9 +45,6 @@ const args = parseArgs({
|
||||
"profile-renderer": { type: "boolean", default: false },
|
||||
trace: { type: "boolean", default: false },
|
||||
"settle-ms": { type: "string", default: "1500" },
|
||||
// Restore the bench window at "x,y" (and treat that display as trusted), for example on a display
|
||||
// that the developer's foreground window does not cover; Chromium stops painting an occluded window.
|
||||
"window-at": { type: "string" },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
@@ -123,12 +112,10 @@ const probe = `(() => ({
|
||||
origin: performance.timeOrigin,
|
||||
firstPaint: performance.getEntriesByType('paint').find((e) => e.name === 'first-paint')?.startTime,
|
||||
domInteractive: performance.getEntriesByType('navigation')[0]?.domInteractive,
|
||||
prepaint: !!document.getElementById('oc-prepaint'),
|
||||
visible: document.visibilityState === 'visible',
|
||||
shell: !!document.querySelector('#root [data-titlebar-tab-link], #root [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('#root [data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('#root [data-timeline-row]').length,
|
||||
home: !!document.querySelector('#root [data-action="home-new-session"], #root [data-action="home-add-project-row"]'),
|
||||
shell: !!document.querySelector('[data-titlebar-tab-link], [data-action="vertical-tabs-home"]'),
|
||||
editor: !!document.querySelector('[data-component="composer-editor"][contenteditable="true"]'),
|
||||
rows: document.querySelectorAll('[data-timeline-row]').length,
|
||||
home: !!document.querySelector('[data-action="home-new-session"], [data-action="home-add-project-row"]'),
|
||||
url: location.pathname + location.search,
|
||||
}))()`
|
||||
// Main-process bootstrap timing, read after the run: when the process was created, when Node
|
||||
@@ -250,8 +237,6 @@ type Probe = {
|
||||
origin: number
|
||||
firstPaint?: number
|
||||
domInteractive?: number
|
||||
prepaint: boolean
|
||||
visible: boolean
|
||||
shell: boolean
|
||||
editor: boolean
|
||||
rows: number
|
||||
@@ -288,7 +273,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const trace = args.values.trace && run === runs
|
||||
const tracePath = join(outDir, `startup-trace-${Date.now()}.json`)
|
||||
const launchArgs = [
|
||||
...(process.env.BENCH_EXTRA_ARGS?.split(" ").filter(Boolean) ?? []),
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
profile ? `--inspect-brk=${inspectPort}` : `--inspect=${inspectPort}`,
|
||||
...(trace
|
||||
@@ -300,12 +284,10 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const raiser = await windowRaiser()
|
||||
const spawnAt = Date.now()
|
||||
const child = spawn(build.exe, launchArgs, { env, detached: true, stdio: "ignore" })
|
||||
child.unref()
|
||||
appPid = child.pid
|
||||
raiser.raise(child.pid!)
|
||||
|
||||
let mainProfile: Promise<unknown> | undefined
|
||||
if (profile) mainProfile = profileMain(spawnAt)
|
||||
@@ -327,7 +309,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
// when the shell is up and the main thread has spent under 10 % of any 500 ms window in tasks for `settleMs`.
|
||||
const seen: Record<string, number> = {}
|
||||
let last: Probe | undefined
|
||||
let prepaintSeen = false
|
||||
let quietSince: number | undefined
|
||||
let taskMs = 0
|
||||
let scriptMs = 0
|
||||
@@ -337,9 +318,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const result = await cdp.send("Runtime.evaluate", { expression: probe, returnByValue: true })
|
||||
last = result.result?.result?.value as Probe | undefined
|
||||
const t = Date.now() - spawnAt
|
||||
if (last?.prepaint) prepaintSeen = true
|
||||
// Chromium marks a window it considers occluded hidden and the renderer stops painting.
|
||||
if (last?.visible && !seen.documentVisible) seen.documentVisible = t
|
||||
if (last?.shell && !seen.shellVisible) seen.shellVisible = t
|
||||
if (last?.editor && !seen.composerEditable) seen.composerEditable = t
|
||||
if (last?.rows && !seen.timelineRows) seen.timelineRows = t
|
||||
@@ -378,7 +356,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
const timelineResult = await cdp.send("Runtime.evaluate", { expression: rendererTimeline, returnByValue: true })
|
||||
cdp.close()
|
||||
const processes = appPid ? await processTree(appPid) : []
|
||||
const screenChanges = await raiser.screen()
|
||||
const boot = profile ? undefined : await mainBootTiming()
|
||||
await sleep(300)
|
||||
// Chromium writes the startup trace when --trace-startup-duration elapses; keep the app alive until then.
|
||||
@@ -408,16 +385,7 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
domInteractive:
|
||||
last?.domInteractive !== undefined && origin !== undefined ? Math.round(origin + last.domInteractive) : undefined,
|
||||
firstPaint: last?.firstPaint !== undefined && origin !== undefined ? Math.round(origin + last.firstPaint) : undefined,
|
||||
// With a shell snapshot in the early document, first paint is the snapshot, not the app.
|
||||
prepaintVisible:
|
||||
prepaintSeen && last?.firstPaint !== undefined && origin !== undefined
|
||||
? Math.round(origin + last.firstPaint)
|
||||
: undefined,
|
||||
...seen,
|
||||
// First sampled screen change inside the window: the ground truth for "the user sees something".
|
||||
screenPainted: screenChanges.changed[0],
|
||||
// When the sampled window content stopped changing: the interface, not a splash, is on screen.
|
||||
screenSettled: screenChanges.settled,
|
||||
rendererIdle: rendererIdleMs,
|
||||
},
|
||||
final: { url: last?.url, timelineRows: last?.rows },
|
||||
@@ -483,22 +451,6 @@ function prepareHome() {
|
||||
})
|
||||
}
|
||||
mkdirSync(paths.logs, { recursive: true })
|
||||
const at = args.values["window-at"]?.split(",").map(Number)
|
||||
if (at?.length === 2 && existsSync(userData)) {
|
||||
for (const file of readdirSync(userData).filter((name) => /^window-state-.*\.json$/.test(name))) {
|
||||
const state = JSON.parse(readFileSync(join(userData, file), "utf8"))
|
||||
const bounds = displayAt(at[0], at[1])
|
||||
writeFileSync(join(userData, file), JSON.stringify({ ...state, x: at[0], y: at[1], isMaximized: false, isFullScreen: false, displayBounds: bounds }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayAt(x: number, y: number) {
|
||||
if (process.platform !== "win32") return undefined
|
||||
const out = execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { \"$($_.Bounds.X),$($_.Bounds.Y),$($_.Bounds.Width),$($_.Bounds.Height)\" }"], { encoding: "utf8" })
|
||||
const displays = out.trim().split(/\r?\n/).map((line) => line.split(",").map(Number))
|
||||
const hit = displays.find(([dx, dy, dw, dh]) => x >= dx && y >= dy && x < dx + dw && y < dy + dh)
|
||||
return hit ? { x: hit[0], y: hit[1], width: hit[2], height: hit[3] } : undefined
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
@@ -613,86 +565,6 @@ function mainLog() {
|
||||
}
|
||||
|
||||
// Working set of every process in the launched app's tree once it is idle.
|
||||
// Windows places a window launched from a background process behind the foreground one, and
|
||||
// Chromium then marks it occluded and the renderer stops painting, so paint timings would depend on
|
||||
// what else is on screen. A helper started before the app polls for its main window and pins it
|
||||
// topmost without activating it, so the user keeps their focus and the bench window is visible.
|
||||
async function windowRaiser(): Promise<{ raise: (pid: number) => void; screen: () => Promise<number[]> }> {
|
||||
if (process.platform !== "win32") return { raise: () => {}, screen: async () => [] }
|
||||
const script = join(outDir, "raise-window.ps1")
|
||||
writeFileSync(
|
||||
script,
|
||||
[
|
||||
`Add-Type -AssemblyName System.Drawing`,
|
||||
`Add-Type -Namespace Bench -Name User32 -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int z, uint f); [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L; public int T; public int R; public int B; } [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);'`,
|
||||
`[Console]::Out.WriteLine("ready")`,
|
||||
`$target = [int][Console]::In.ReadLine()`,
|
||||
`$clock = [Diagnostics.Stopwatch]::StartNew()`,
|
||||
`$h = 0`,
|
||||
`for ($i = 0; $i -lt 600; $i++) {`,
|
||||
` $h = (Get-Process -Id $target -ErrorAction SilentlyContinue).MainWindowHandle`,
|
||||
` if ($h -and $h -ne 0) { [Bench.User32]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x13) | Out-Null; [Console]::Out.WriteLine("raised " + $clock.ElapsedMilliseconds); break }`,
|
||||
` Start-Sleep -Milliseconds 10`,
|
||||
`}`,
|
||||
`if (-not $h -or $h -eq 0) { exit }`,
|
||||
// Sample a 16x16 grid of pixels inside the window: cheap, and enough to tell the background
|
||||
// colour, a splash and the interface apart.
|
||||
`$r = New-Object Bench.User32+RECT`,
|
||||
`while ($clock.ElapsedMilliseconds -lt 3000) {`,
|
||||
` [Bench.User32]::GetWindowRect($h, [ref]$r) | Out-Null`,
|
||||
` $w = $r.R - $r.L; $ht = $r.B - $r.T`,
|
||||
` if ($w -le 48 -or $ht -le 48) { Start-Sleep -Milliseconds 30; continue }`,
|
||||
` $t = $clock.ElapsedMilliseconds`,
|
||||
` $bmp = New-Object System.Drawing.Bitmap $w, $ht`,
|
||||
` $g = [System.Drawing.Graphics]::FromImage($bmp)`,
|
||||
` try { $g.CopyFromScreen($r.L, $r.T, 0, 0, $bmp.Size) } catch { $g.Dispose(); $bmp.Dispose(); Start-Sleep -Milliseconds 30; continue }`,
|
||||
` $sum = 0`,
|
||||
` for ($i = 1; $i -le 16; $i++) { for ($j = 1; $j -le 16; $j++) { $p = $bmp.GetPixel([int]($w * $j / 17), [int]($ht * $i / 17)); $sum += [int]$p.R + [int]$p.G + [int]$p.B } }`,
|
||||
` [Console]::Out.WriteLine("screen " + $t + " " + $sum)`,
|
||||
` if ($env:BENCH_SCREEN_DUMP) { $bmp.Save((Join-Path $env:BENCH_SCREEN_DUMP ("screen-" + $t.ToString().PadLeft(4, "0") + ".png"))) }`,
|
||||
` $g.Dispose(); $bmp.Dispose()`,
|
||||
` Start-Sleep -Milliseconds 30`,
|
||||
`}`,
|
||||
].join("\n"),
|
||||
)
|
||||
const helper = spawn("pwsh", ["-NoProfile", "-NonInteractive", "-File", script], { stdio: ["pipe", "pipe", "pipe"] })
|
||||
const lines: string[] = []
|
||||
let buffer = ""
|
||||
await new Promise<void>((resolve) => {
|
||||
helper.stdout!.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString()
|
||||
const parts = buffer.split(/\r?\n/)
|
||||
buffer = parts.pop() ?? ""
|
||||
for (const line of parts) {
|
||||
if (line === "ready") resolve()
|
||||
else lines.push(line)
|
||||
}
|
||||
})
|
||||
helper.stderr!.on("data", (chunk: Buffer) => console.error(`raise-window: ${chunk.toString().trim()}`))
|
||||
helper.on("exit", () => resolve())
|
||||
})
|
||||
const exited = new Promise<void>((resolve) => helper.on("exit", () => resolve()))
|
||||
return {
|
||||
raise: (pid) => helper.stdin!.write(`${pid}\n`),
|
||||
// Resolves with the times (ms since the pid was sent, ~spawn) at which the sampled screen
|
||||
// content differed from the first sample, i.e. when something other than the background colour
|
||||
// was on screen.
|
||||
screen: async () => {
|
||||
await exited
|
||||
const samples = lines
|
||||
.filter((line) => line.startsWith("screen "))
|
||||
.map((line) => line.split(" ").slice(1).map(Number) as [number, number])
|
||||
if (process.env.BENCH_DEBUG) console.log(lines.filter((line) => line.startsWith("raised")).join(" "), `${samples.length} screen samples`)
|
||||
const first = samples[0]?.[1]
|
||||
const last = samples.at(-1)?.[1]
|
||||
const differs = (a: number, b: number) => Math.abs(a - b) > 16 * 16 * 12
|
||||
const changed = samples.filter(([, sum]) => differs(sum, first)).map(([t]) => t)
|
||||
// The last sample that still differed from the final content, i.e. when the window stopped changing.
|
||||
const settledIndex = samples.findLastIndex(([, sum]) => last !== undefined && differs(sum, last))
|
||||
return { changed, settled: settledIndex >= 0 ? samples[settledIndex + 1]?.[0] : samples[0]?.[0] }
|
||||
},
|
||||
}
|
||||
}
|
||||
async function processTree(root: number) {
|
||||
const script =
|
||||
process.platform === "win32"
|
||||
|
||||
@@ -2,7 +2,14 @@ import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { WindowRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, setWindowThemeReady, updateTitlebar } from "../windows"
|
||||
import {
|
||||
getPinchZoomEnabled,
|
||||
saveWindowPrepaint,
|
||||
setPinchZoomEnabled,
|
||||
setTitlebar,
|
||||
setWindowThemeReady,
|
||||
updateTitlebar,
|
||||
} from "../windows"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const windowHandlers = WindowRpcs.toLayer(
|
||||
@@ -38,6 +45,11 @@ export const windowHandlers = WindowRpcs.toLayer(
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (win) setTitlebar(win, theme)
|
||||
}),
|
||||
WindowSavePrepaint: ({ html }, context) =>
|
||||
Effect.promise(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
return win ? saveWindowPrepaint(win, html) : Promise.resolve()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as Ipc from "./ipc"
|
||||
|
||||
import { app, BrowserWindow, MessageChannelMain } from "electron"
|
||||
import { app, BrowserWindow, ipcMain, MessageChannelMain } from "electron"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { RpcServer } from "effect/unstable/rpc"
|
||||
import { DesktopRpcs } from "../shared/ipc-rpc"
|
||||
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
|
||||
import { DesktopFiles, openExternalURL } from "./files"
|
||||
import { appHandlers } from "./ipc-handlers/app"
|
||||
import { eventHandlers } from "./ipc-handlers/events"
|
||||
@@ -23,6 +23,7 @@ import { createMenu, sendMenuCommand } from "./native/menu"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { Updater } from "./updater"
|
||||
import { getLastFocusedWindow } from "./windows"
|
||||
import { isRendererUrl } from "./windows/protocol"
|
||||
import { Wsl } from "./wsl/start"
|
||||
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer, Ssh.layer)
|
||||
@@ -65,18 +66,27 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(win.webContents, channel.port1)
|
||||
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
})
|
||||
}
|
||||
// Each renderer document asks for its own port once its client is listening; see ipc-client.ts.
|
||||
const handPort = (event: Electron.IpcMainEvent) => {
|
||||
const contents = event.sender
|
||||
if (contents.isDestroyed() || !isRendererUrl(contents.getURL())) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(contents, channel.port1)
|
||||
contents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
app.on("browser-window-created", wire)
|
||||
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
|
||||
ipcMain.on(IpcTransportPortRequest, handPort)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
|
||||
yield* Effect.addFinalizer(
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
app.off("browser-window-created", wire)
|
||||
ipcMain.off(IpcTransportPortRequest, handPort)
|
||||
}),
|
||||
)
|
||||
return {
|
||||
installMenu: () => createMenu(menu),
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ export function setZoomFactor(win: BrowserWindow, factor: number) {
|
||||
|
||||
export function wireZoom(win: BrowserWindow) {
|
||||
pinchZoomEnabled.set(win, getPinchZoomEnabled())
|
||||
win.webContents.setZoomFactor(1)
|
||||
// Setting the factor forces a visual-properties round trip with the renderer, so leave it alone
|
||||
// when it is already 1: the first window has a document on screen by now.
|
||||
if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
|
||||
win.webContents.on("zoom-changed", (event, direction) => {
|
||||
event.preventDefault()
|
||||
if (pinchZoomEnabled.get(win)) {
|
||||
|
||||
@@ -6,9 +6,11 @@ import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
import { earlyQuery, serveRenderer } from "./serve"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
|
||||
|
||||
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
|
||||
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number; loaded: boolean }
|
||||
|
||||
let pending: EarlyWindow | undefined
|
||||
|
||||
@@ -56,7 +58,16 @@ export function createEarlyWindow() {
|
||||
pending = undefined
|
||||
app.quit()
|
||||
})
|
||||
pending = { id, win, state, shownAt: Date.now() }
|
||||
// In production the window starts its document now - the same index.html without its scripts,
|
||||
// carrying the shell snapshot from the previous run - so the renderer process and the stylesheet
|
||||
// are ready, and the user sees their UI, while the bundle and the layers load. The scripts are
|
||||
// added when restoreWindows() adopts the window. Development keeps loading from the dev server.
|
||||
const loaded = !process.env.ELECTRON_RENDERER_URL
|
||||
if (loaded) {
|
||||
serveRenderer(path.join(root, "../renderer"))
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/index.html?${earlyQuery}=${encodeURIComponent(id)}`)
|
||||
}
|
||||
pending = { id, win, state, shownAt: Date.now(), loaded }
|
||||
}
|
||||
|
||||
export function takeEarlyWindow() {
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
wireZoom,
|
||||
} from "./appearance"
|
||||
import { loadWindow, registerRendererProtocol } from "./protocol"
|
||||
import { removePrepaint, writePrepaint } from "./prepaint"
|
||||
import { releaseRenderer } from "./serve"
|
||||
import { createWindowRegistry } from "./registry"
|
||||
import { makeWindowRecovery } from "./recovery"
|
||||
import { takeEarlyWindow, type EarlyWindow } from "./early"
|
||||
@@ -29,6 +31,7 @@ import { manageWindowState, readWindowState, resolveWindowState, windowStateFile
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
|
||||
const themeReady = new WeakMap<BrowserWindow, () => void>()
|
||||
const windowIDs = new WeakMap<BrowserWindow, string>()
|
||||
const displays = {
|
||||
all: () => screen.getAllDisplays().map((display) => display.bounds),
|
||||
primary: () => screen.getPrimaryDisplay().bounds,
|
||||
@@ -80,6 +83,12 @@ export function setWindowThemeReady(win: BrowserWindow) {
|
||||
themeReady.get(win)?.()
|
||||
}
|
||||
|
||||
export function saveWindowPrepaint(win: BrowserWindow, html: string) {
|
||||
const id = windowIDs.get(win)
|
||||
if (!id) return Promise.resolve()
|
||||
return writePrepaint(id, html)
|
||||
}
|
||||
|
||||
export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
@@ -126,7 +135,13 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
if (!early) manageWindowState(win, stateFile, state, displays)
|
||||
register(win, id)
|
||||
wireFullscreen(win)
|
||||
loadWindow(win, "index.html")
|
||||
if (early?.loaded) {
|
||||
runFork(
|
||||
Effect.tryPromise(() => releaseRenderer(win, paths.rendererRoot)).pipe(
|
||||
Effect.catch((error) => scoped("window", Effect.logError("failed to release early renderer", { id, error }))),
|
||||
),
|
||||
)
|
||||
} else loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
let contentReady = false
|
||||
let appliedTheme = false
|
||||
@@ -163,6 +178,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
|
||||
const register = (win: BrowserWindow, id: string) => {
|
||||
registry.register(id, win)
|
||||
windowIDs.set(win, id)
|
||||
win.on("focus", () => registry.focused(id))
|
||||
// Windows emits session-end, but not before-quit, during shutdown and logoff.
|
||||
win.on("session-end", () => registry.setQuitting())
|
||||
@@ -172,6 +188,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
|
||||
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
|
||||
yield* Effect.promise(() => removePrepaint(id))
|
||||
}).pipe(
|
||||
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { mkdir, rename, rm, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { app } from "electron"
|
||||
|
||||
// The shell snapshot a window shows before its renderer has booted. It is a file next to the
|
||||
// window-state JSON rather than a row in the desktop database because the entry module serves it
|
||||
// before the storage layers exist; a snapshot from another app version is ignored, since its class
|
||||
// names may no longer match the bundled stylesheet.
|
||||
|
||||
export function prepaintFile(id: string) {
|
||||
return `prepaint-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.html`
|
||||
}
|
||||
|
||||
export function prepaintMarker(version: string) {
|
||||
return `<!-- opencode ${version} -->`
|
||||
}
|
||||
|
||||
export async function writePrepaint(id: string, html: string) {
|
||||
const file = path.join(app.getPath("userData"), prepaintFile(id))
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await writeFile(`${file}.tmp`, `${prepaintMarker(app.getVersion())}\n${html}`)
|
||||
await rename(`${file}.tmp`, file)
|
||||
}
|
||||
|
||||
export function removePrepaint(id: string) {
|
||||
return rm(path.join(app.getPath("userData"), prepaintFile(id)), { force: true })
|
||||
}
|
||||
@@ -1,54 +1,19 @@
|
||||
import { net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Effect, Path } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
import { serveRenderer, setRendererProtocolLogger } from "./serve"
|
||||
|
||||
// The entry module normally registers the handler before the bundle loads; this only wires its
|
||||
// logging, and registers it when the entry module did not (development, or a window created later).
|
||||
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(paths.rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
runFork(
|
||||
scoped(
|
||||
"protocol",
|
||||
Effect.logError("fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
setRendererProtocolLogger((level, message, data) =>
|
||||
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
|
||||
)
|
||||
serveRenderer(paths.rendererRoot)
|
||||
})
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
@@ -68,11 +33,4 @@ export function isRendererUrl(value?: string, html = false) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { app, net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
import { prepaintFile, prepaintMarker } from "./prepaint"
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
|
||||
// Serves the renderer bundle over oc://renderer. This module has no Effect dependency because the
|
||||
// entry module registers it right after Electron is ready, before the main bundle has loaded, so
|
||||
// the first window can start its document while the bundle and the layers evaluate.
|
||||
|
||||
export const earlyQuery = "early"
|
||||
|
||||
type Log = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
|
||||
let log: Log = () => {}
|
||||
|
||||
export function setRendererProtocolLogger(logger: Log) {
|
||||
log = logger
|
||||
}
|
||||
|
||||
export function serveRenderer(rendererRoot: string) {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
log("warning", "rejected host", { url: request.url })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
log("warning", "rejected path", { url: request.url, file })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const early = url.pathname === "/index.html" ? url.searchParams.get(earlyQuery) : null
|
||||
if (early !== null) return earlyDocument(file, early)
|
||||
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
log("error", "fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
log("error", "fetch error", { url: request.url, file, error })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The early window loads index.html?early=<window id>: the same document without its module
|
||||
// scripts, plus the shell snapshot the window captured last time, so the user sees their own UI
|
||||
// while the main process is still loading. releaseRenderer() adds the scripts back once the window
|
||||
// has been adopted.
|
||||
async function earlyDocument(file: string, id: string) {
|
||||
const [html, prepaint] = await Promise.all([
|
||||
readFile(file, "utf8"),
|
||||
readFile(path.join(app.getPath("userData"), prepaintFile(id)), "utf8").catch(() => undefined),
|
||||
])
|
||||
const body = prepaint?.startsWith(prepaintMarker(app.getVersion()))
|
||||
? `<div id="oc-prepaint" inert aria-hidden="true" style="position:fixed;inset:0;z-index:100;pointer-events:none;display:flex;flex-direction:column;background-color:var(--background-base)">${prepaint.slice(prepaint.indexOf("\n") + 1)}</div></body>`
|
||||
: "</body>"
|
||||
const text = html.replace(moduleScript, "").replace("</body>", body)
|
||||
return new Response(text, {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
[documentPolicyHeader]: jsCallStacksDocumentPolicy,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const moduleScript = /<script type="module"[^>]*><\/script>\s*/g
|
||||
|
||||
export async function releaseRenderer(win: BrowserWindow, rendererRoot: string) {
|
||||
const html = await readFile(path.join(rendererRoot, "index.html"), "utf8")
|
||||
const srcs = [...html.matchAll(moduleScript)].flatMap((match) => {
|
||||
const src = /\bsrc="([^"]+)"/.exec(match[0])
|
||||
return src ? [src[1]] : []
|
||||
})
|
||||
if (win.isDestroyed()) return
|
||||
// async = false keeps the runtime chunk ahead of the entry, as the static tags did. Dropping the
|
||||
// query afterwards makes a reload load the full document.
|
||||
await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
for (const src of ${JSON.stringify(srcs)}) {
|
||||
const script = document.createElement("script")
|
||||
script.type = "module"
|
||||
script.async = false
|
||||
script.crossOrigin = ""
|
||||
script.src = src
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
history.replaceState(history.state, "", "/index.html")
|
||||
})()`,
|
||||
)
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { listInstalledWslDistros, probeWslRuntime, runWslSh, wslArgs } from "./runtime"
|
||||
|
||||
test("wslArgs bypasses the distro default shell", () => {
|
||||
expect(wslArgs(["sh", "-lc", 'printf "%s\\n" "$HOME"'], "Debian")).toEqual([
|
||||
"-d",
|
||||
"Debian",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'printf "%s\\n" "$HOME"',
|
||||
])
|
||||
expect(wslArgs(["bash", "-se"], "Debian", "root")).toEqual([
|
||||
"-d",
|
||||
"Debian",
|
||||
"--user",
|
||||
"root",
|
||||
"--exec",
|
||||
"bash",
|
||||
"-se",
|
||||
])
|
||||
})
|
||||
|
||||
// Exercise the real wsl.exe argv path: with `--`, WSL routes the command line through the distro's
|
||||
// default shell, which expands `$cli` to "" before `sh` runs the script (#48640).
|
||||
const distro =
|
||||
process.platform === "win32" && (await probeWslRuntime()).available
|
||||
? (await listInstalledWslDistros().catch(() => [])).find((item) => item.isDefault)?.name
|
||||
: undefined
|
||||
|
||||
test.skipIf(!distro)("inline scripts keep their own variable expansion inside WSL", async () => {
|
||||
const result = await runWslSh(['cli="from-script"', 'printf "%s\\n" "$cli"'].join("\n"), distro)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout.trim()).toBe("from-script")
|
||||
})
|
||||
@@ -38,9 +38,7 @@ const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
export function wslArgs(args: string[], distro?: string | null, user?: string | null) {
|
||||
// `--` hands the command line to the distro's default shell, which expands `$VAR` and `$(...)`
|
||||
// inside our inline scripts before `sh -lc` ever runs them. `--exec` runs the command directly.
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--exec", ...args]
|
||||
return [...(distro ? ["-d", distro] : []), ...(user ? ["--user", user] : []), "--", ...args]
|
||||
}
|
||||
|
||||
export function runWsl(args: string[], opts: RunWslOptions = {}) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron"
|
||||
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
|
||||
import { DragCancelEvent, IpcTransportPort, IpcTransportPortRequest } from "../shared/ipc-transport"
|
||||
import { windowIDFromArguments } from "../shared/window-bootstrap"
|
||||
|
||||
ipcRenderer.on(IpcTransportPort, (event) => {
|
||||
@@ -11,5 +11,6 @@ ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelE
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
windowID: windowIDFromArguments(process.argv),
|
||||
requestRpcPort: () => ipcRenderer.send(IpcTransportPortRequest),
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type ElectronNative = {
|
||||
windowID: string
|
||||
requestRpcPort(): void
|
||||
getPathForFile(file: File): string
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export type ElectronAPI = {
|
||||
draftBlobGet(id: string): Promise<ArrayBuffer | null>
|
||||
getWindowID(): string
|
||||
themeReady(): Promise<void>
|
||||
// Persists the sanitized shell markup the next launch shows before the renderer boots.
|
||||
savePrepaint(html: string): Promise<void>
|
||||
onMenuCommand(cb: (id: string) => void): () => void
|
||||
onDeepLink(cb: (urls: string[]) => void): () => void
|
||||
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
|
||||
|
||||
@@ -112,6 +112,7 @@ export const api: ElectronAPI = {
|
||||
|
||||
getWindowID: () => window.electron.windowID,
|
||||
themeReady: () => invoke("WindowThemeReady"),
|
||||
savePrepaint: (html) => invoke("WindowSavePrepaint", { html }),
|
||||
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
|
||||
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@opencode/app/desktop"
|
||||
import { useTheme } from "@opencode/ui/theme/context"
|
||||
import type { BaseRouterProps } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, lazy, on, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ElectronAPI } from "./api-types"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
@@ -27,6 +27,7 @@ import { createDesktopPlatform } from "./platform"
|
||||
import { bindDesktopMenu } from "./platform/menu"
|
||||
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
|
||||
import { preloadStoredLocale } from "./startup/locale"
|
||||
import { hasPrepaint, removePrepaint, schedulePrepaintCapture } from "./startup/prepaint"
|
||||
import { LoadingSplash } from "./startup/splash"
|
||||
import { getLastActiveUrl } from "./window/route-storage"
|
||||
import { DesktopMemoryRouter } from "./window/router"
|
||||
@@ -40,9 +41,12 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
|
||||
const initialUrl = getLastActiveUrl(windowState.id)
|
||||
const url = new URL(initialUrl, "http://localhost")
|
||||
const route = currentRoute(url.pathname, url.search)
|
||||
// With a shell snapshot on screen the splash is not needed; the snapshot is removed when the
|
||||
// interface would otherwise be revealed.
|
||||
const prepaint = hasPrepaint()
|
||||
const [startup, setStartup] = createStore({
|
||||
ready: false,
|
||||
visible: true,
|
||||
visible: !prepaint,
|
||||
themeReady: false,
|
||||
onboardingReady: false,
|
||||
drawingReady: false,
|
||||
@@ -72,6 +76,25 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
|
||||
if (!startup.themeReady || firstLaunch.loading) return
|
||||
void props.api.themeReady()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!prepaint || firstLaunch() !== true) return
|
||||
removePrepaint()
|
||||
setStartup("visible", true)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!readyToReveal()) return
|
||||
removePrepaint()
|
||||
schedulePrepaintCapture(props.api.savePrepaint)
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => startup.route,
|
||||
() => {
|
||||
if (startup.ready) schedulePrepaintCapture(props.api.savePrepaint, 3000)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
function ReadyApp() {
|
||||
const wslServers = useWslServers()
|
||||
|
||||
@@ -13,6 +13,8 @@ type InvokeResult<Tag extends InvokeTag> =
|
||||
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
|
||||
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
|
||||
|
||||
// The renderer asks for its port rather than receiving one on load: the first window's document
|
||||
// is on screen before its scripts, and before the main process has its IPC layer, exist.
|
||||
const port = new Promise<MessagePort>((resolve) => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window || event.data !== IpcTransportPort) return
|
||||
@@ -22,6 +24,7 @@ const port = new Promise<MessagePort>((resolve) => {
|
||||
resolve(value)
|
||||
}
|
||||
window.addEventListener("message", onMessage)
|
||||
window.electron.requestRpcPort()
|
||||
})
|
||||
|
||||
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// The early document carries a static copy of this window's shell from the previous run, served by
|
||||
// the main process as #oc-prepaint, so the user sees their UI while the renderer boots. It is
|
||||
// removed when the real interface is ready to be revealed, and re-captured from the live DOM so the
|
||||
// next launch shows the current state.
|
||||
|
||||
const prepaintID = "oc-prepaint"
|
||||
const maxBytes = 1_000_000
|
||||
|
||||
export function hasPrepaint() {
|
||||
return document.getElementById(prepaintID) !== null
|
||||
}
|
||||
|
||||
export function removePrepaint() {
|
||||
document.getElementById(prepaintID)?.remove()
|
||||
}
|
||||
|
||||
let timer: number | undefined
|
||||
|
||||
// Captures once the renderer is idle; `delay` coalesces bursts of route changes into one capture.
|
||||
export function schedulePrepaintCapture(save: (html: string) => Promise<void>, delay = 0) {
|
||||
clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
const root = document.getElementById("root")
|
||||
const html = root && capturePrepaint(root)
|
||||
if (html) void save(html).catch(() => undefined)
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Elements that hold live or transient state and would look wrong, or leak, in a static copy.
|
||||
const dropped =
|
||||
"script, style, link, iframe, object, embed, canvas, video, audio, dialog, [popover], [role='dialog'], [role='menu'], [role='listbox'], [role='tooltip'], [data-component='startup-overlay']"
|
||||
// Attributes that could act, be targeted, or collide with the live document once it mounts.
|
||||
const stripped = new Set(["id", "href", "tabindex", "contenteditable", "autofocus", "for", "name", "action", "formaction"])
|
||||
|
||||
export function capturePrepaint(root: HTMLElement) {
|
||||
const clone = root.cloneNode(true) as HTMLElement
|
||||
clone.querySelectorAll(dropped).forEach((element) => element.remove())
|
||||
const symbols = new Set<string>()
|
||||
for (const element of clone.querySelectorAll("*")) {
|
||||
if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) element.removeAttribute("value")
|
||||
if (element.hasAttribute("contenteditable")) element.replaceChildren()
|
||||
for (const attribute of Array.from(element.attributes)) {
|
||||
const name = attribute.name
|
||||
if (name.startsWith("on")) element.removeAttribute(name)
|
||||
// SVG keeps its ids and hrefs: gradients, clip paths and <use> references are local visuals.
|
||||
if (element instanceof SVGElement) {
|
||||
if (name === "href" && attribute.value.startsWith("#")) symbols.add(attribute.value.slice(1))
|
||||
continue
|
||||
}
|
||||
if (stripped.has(name)) element.removeAttribute(name)
|
||||
if (name === "src" && !/^(\.\/|\/|oc:|data:)/.test(attribute.value)) element.removeAttribute(name)
|
||||
}
|
||||
}
|
||||
const root_ = document.documentElement
|
||||
const html = `<div class="${document.body.className} flex flex-col h-dvh" lang="${root_.lang}" dir="${root_.dir}">${sprite(symbols)}${clone.innerHTML}</div>`
|
||||
return html.length <= maxBytes ? html : undefined
|
||||
}
|
||||
|
||||
// Icons are <use href="#symbol"> into a sprite outside #root; copy only the symbols the shell uses.
|
||||
function sprite(symbols: Set<string>) {
|
||||
const defs = [...symbols]
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter((element) => element instanceof SVGSymbolElement)
|
||||
.map((element) => element.outerHTML)
|
||||
if (!defs.length) return ""
|
||||
return `<svg aria-hidden="true" width="0" height="0" style="position:absolute;overflow:hidden">${defs.join("")}</svg>`
|
||||
}
|
||||
@@ -24,6 +24,9 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
|
||||
}),
|
||||
},
|
||||
})
|
||||
export const WindowSavePrepaint = Rpc.make("WindowSavePrepaint", {
|
||||
payload: { html: Schema.String },
|
||||
})
|
||||
export const WindowRpcs = RpcGroup.make(
|
||||
WindowThemeReady,
|
||||
WindowGetFocused,
|
||||
@@ -35,4 +38,5 @@ export const WindowRpcs = RpcGroup.make(
|
||||
WindowGetPinchZoomEnabled,
|
||||
WindowSetPinchZoomEnabled,
|
||||
WindowSetTitlebar,
|
||||
WindowSavePrepaint,
|
||||
)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const IpcTransportPort = "desktop-rpc-port"
|
||||
export const IpcTransportPortRequest = "desktop-rpc-port-request"
|
||||
export const DragCancelEvent = "opencode:drag-cancel"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user