mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-04 07:40:39 +08:00
Compare commits
77 Commits
thdxr/v2-m
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d55976ad | ||
|
|
a6f39942a8 | ||
|
|
7585103a80 | ||
|
|
66537f4db4 | ||
|
|
c3d088fe27 | ||
|
|
ce89bcb8e2 | ||
|
|
c2b1974ddd | ||
|
|
ca6150d6f0 | ||
|
|
825ab2e38d | ||
|
|
755cd561ec | ||
|
|
6312c55d55 | ||
|
|
a9dc0fae3d | ||
|
|
7749d8e85f | ||
|
|
28112fbd12 | ||
|
|
387220f368 | ||
|
|
adb7cb1037 | ||
|
|
9b85d2cbb4 | ||
|
|
4a86c2b77a | ||
|
|
d704110e52 | ||
|
|
09e4e5a184 | ||
|
|
e041605b40 | ||
|
|
f280e7e69c | ||
|
|
b265742fd0 | ||
|
|
b1db69fdf7 | ||
|
|
031766efa0 | ||
|
|
dc6d39551c | ||
|
|
e287569f82 | ||
|
|
14eacb4019 | ||
|
|
731c1e58f2 | ||
|
|
c411d37484 | ||
|
|
cb29742b57 | ||
|
|
97a94571a4 | ||
|
|
6652585a7f | ||
|
|
532b64c0d5 | ||
|
|
eec4c775a7 | ||
|
|
01e350449c | ||
|
|
5792a80a8c | ||
|
|
db039db7f5 | ||
|
|
c1a3936b61 | ||
|
|
a9d9e4d9c4 | ||
|
|
2531b2d3a9 | ||
|
|
a718f86e0f | ||
|
|
f3efdff861 | ||
|
|
955d8591df | ||
|
|
33b3388bf4 | ||
|
|
716f40b128 | ||
|
|
0b06ff1407 | ||
|
|
01ff5b5390 | ||
|
|
3d1b121e70 | ||
|
|
b70629af27 | ||
|
|
b7b016fa28 | ||
|
|
5ba2d7e5f0 | ||
|
|
459b22b83d | ||
|
|
377812b98a | ||
|
|
5cc0901e38 | ||
|
|
7fb6b589d1 | ||
|
|
3f37b43e7d | ||
|
|
8805dfc849 | ||
|
|
ac5a5d8b16 | ||
|
|
eaf94ed047 | ||
|
|
b8031c5ae8 | ||
|
|
a531f3f36d | ||
|
|
bb3382311d | ||
|
|
ad545d0cc9 | ||
|
|
ac244b1458 | ||
|
|
f202536b65 | ||
|
|
405cc3f610 | ||
|
|
878c1b8c2d | ||
|
|
d5dcadc000 | ||
|
|
0c154e6a2f | ||
|
|
4f96975148 | ||
|
|
eaba99711b | ||
|
|
f762125775 | ||
|
|
ded6bb6513 | ||
|
|
39332f5be6 | ||
|
|
2c6ff35400 | ||
|
|
738d6c8899 |
@@ -15,6 +15,7 @@ import { terminalFontFamily, useSettings } from "@/context/settings"
|
||||
import type { LocalPTY } from "@/context/terminal"
|
||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||
import { terminalWriter } from "@/utils/terminal-writer"
|
||||
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
|
||||
|
||||
const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||
@@ -67,13 +68,6 @@ const debugTerminal = (...values: unknown[]) => {
|
||||
console.debug("[terminal]", ...values)
|
||||
}
|
||||
|
||||
const errorName = (err: unknown) => {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("name" in err)) return
|
||||
const errorName = err.name
|
||||
return typeof errorName === "string" ? errorName : undefined
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
@@ -478,10 +472,9 @@ export const Terminal = (props: TerminalProps) => {
|
||||
|
||||
const gone = () =>
|
||||
client.pty
|
||||
.get({ ptyID: id })
|
||||
.then(() => false)
|
||||
.get({ ptyID: id }, { throwOnError: false })
|
||||
.then((result) => result.response.status === 404)
|
||||
.catch((err) => {
|
||||
if (errorName(err) === "NotFoundError") return true
|
||||
debugTerminal("failed to inspect terminal session", err)
|
||||
return false
|
||||
})
|
||||
@@ -509,18 +502,18 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (disposed) return
|
||||
drop?.()
|
||||
|
||||
const next = new URL(url + `/pty/${id}/connect`)
|
||||
next.searchParams.set("directory", directory)
|
||||
next.searchParams.set("cursor", String(seek))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (!sameOrigin && password) {
|
||||
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
||||
// For same-origin requests, let the browser reuse the page's existing auth.
|
||||
next.username = username
|
||||
next.password = password
|
||||
}
|
||||
|
||||
const socket = new WebSocket(next)
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({
|
||||
url,
|
||||
id,
|
||||
directory,
|
||||
cursor: seek,
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
authToken: server.current?.type === "http" ? server.current.authToken : false,
|
||||
}),
|
||||
)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
|
||||
|
||||
53
packages/app/src/context/server.test.ts
Normal file
53
packages/app/src/context/server.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveServerList, ServerConnection } from "./server"
|
||||
|
||||
describe("resolveServerList", () => {
|
||||
test("lets startup auth_token credentials override a persisted same-url server", () => {
|
||||
const list = resolveServerList({
|
||||
stored: [{ url: "https://server.example.test" }],
|
||||
props: [
|
||||
{
|
||||
type: "http",
|
||||
authToken: true,
|
||||
http: {
|
||||
url: "https://server.example.test",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]?.type).toBe("http")
|
||||
expect(list[0]?.http).toEqual({
|
||||
url: "https://server.example.test",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
})
|
||||
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
|
||||
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
|
||||
})
|
||||
|
||||
test("keeps persisted credentials when startup has no auth_token", () => {
|
||||
const list = resolveServerList({
|
||||
stored: [
|
||||
{
|
||||
url: "https://server.example.test",
|
||||
username: "opencode",
|
||||
password: "saved",
|
||||
},
|
||||
],
|
||||
props: [{ type: "http", http: { url: "https://server.example.test" } }],
|
||||
})
|
||||
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]?.type).toBe("http")
|
||||
expect(list[0]?.http).toEqual({
|
||||
url: "https://server.example.test",
|
||||
username: "opencode",
|
||||
password: "saved",
|
||||
})
|
||||
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,33 @@ function isLocalHost(url: string) {
|
||||
if (host === "localhost" || host === "127.0.0.1") return "local"
|
||||
}
|
||||
|
||||
export function resolveServerList(input: {
|
||||
props?: Array<ServerConnection.Any>
|
||||
stored: StoredServer[]
|
||||
}): Array<ServerConnection.Any> {
|
||||
const servers = [
|
||||
...input.stored.map((value) =>
|
||||
typeof value === "string"
|
||||
? {
|
||||
type: "http" as const,
|
||||
http: { url: value },
|
||||
}
|
||||
: value,
|
||||
),
|
||||
...(input.props ?? []),
|
||||
]
|
||||
|
||||
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
|
||||
for (const value of servers) {
|
||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
||||
const key = ServerConnection.key(conn)
|
||||
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
|
||||
deduped.set(key, conn)
|
||||
}
|
||||
|
||||
return [...deduped.values()]
|
||||
}
|
||||
|
||||
export namespace ServerConnection {
|
||||
type Base = { displayName?: string }
|
||||
|
||||
@@ -46,6 +73,7 @@ export namespace ServerConnection {
|
||||
export type Http = {
|
||||
type: "http"
|
||||
http: HttpBase
|
||||
authToken?: boolean
|
||||
} & Base
|
||||
|
||||
export type Sidecar = {
|
||||
@@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
||||
|
||||
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
||||
const servers = [
|
||||
...(props.servers ?? []),
|
||||
...store.list.map((value) =>
|
||||
typeof value === "string"
|
||||
? {
|
||||
type: "http" as const,
|
||||
http: { url: value },
|
||||
}
|
||||
: value,
|
||||
),
|
||||
]
|
||||
|
||||
const deduped = new Map(
|
||||
servers.map((value) => {
|
||||
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
|
||||
return [ServerConnection.key(conn), conn]
|
||||
}),
|
||||
)
|
||||
|
||||
return [...deduped.values()]
|
||||
return resolveServerList({ stored: store.list, props: props.servers })
|
||||
})
|
||||
|
||||
const [state, setState] = createStore({
|
||||
@@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
function add(input: ServerConnection.Http) {
|
||||
const url_ = normalizeServerUrl(input.http.url)
|
||||
if (!url_) return
|
||||
const conn = { ...input, http: { ...input.http, url: url_ } }
|
||||
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
|
||||
return batch(() => {
|
||||
const existing = store.list.findIndex((x) => url(x) === url_)
|
||||
if (existing !== -1) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { handleNotificationClick } from "@/utils/notification-click"
|
||||
import { authFromToken } from "@/utils/server"
|
||||
import pkg from "../package.json"
|
||||
import { ServerConnection } from "./context/server"
|
||||
|
||||
@@ -111,6 +112,13 @@ const getDefaultUrl = () => {
|
||||
return getCurrentUrl()
|
||||
}
|
||||
|
||||
const clearAuthToken = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (!params.has("auth_token")) return
|
||||
params.delete("auth_token")
|
||||
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
|
||||
}
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
version: pkg.version,
|
||||
@@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
}
|
||||
|
||||
if (root instanceof HTMLElement) {
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: getCurrentUrl(),
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={platform}>
|
||||
|
||||
23
packages/app/src/utils/server.test.ts
Normal file
23
packages/app/src/utils/server.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { authFromToken, authTokenFromCredentials } from "./server"
|
||||
|
||||
describe("authFromToken", () => {
|
||||
test("decodes basic auth credentials from auth_token", () => {
|
||||
expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
|
||||
})
|
||||
|
||||
test("defaults blank username to opencode", () => {
|
||||
expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
|
||||
})
|
||||
|
||||
test("ignores malformed tokens", () => {
|
||||
expect(authFromToken("not base64")).toBeUndefined()
|
||||
expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("authTokenFromCredentials", () => {
|
||||
test("encodes credentials with the default username", () => {
|
||||
expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,21 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
export function authTokenFromCredentials(input: { username?: string; password: string }) {
|
||||
return btoa(`${input.username ?? "opencode"}:${input.password}`)
|
||||
}
|
||||
|
||||
export function authFromToken(token: string | null) {
|
||||
const decoded = decode64(token ?? undefined)
|
||||
if (!decoded) return
|
||||
const separator = decoded.indexOf(":")
|
||||
if (separator === -1) return
|
||||
return {
|
||||
username: decoded.slice(0, separator) || "opencode",
|
||||
password: decoded.slice(separator + 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function createSdkForServer({
|
||||
server,
|
||||
@@ -10,7 +26,7 @@ export function createSdkForServer({
|
||||
const auth = (() => {
|
||||
if (!server.password) return
|
||||
return {
|
||||
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
52
packages/app/src/utils/terminal-websocket-url.test.ts
Normal file
52
packages/app/src/utils/terminal-websocket-url.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { terminalWebSocketURL } from "./terminal-websocket-url"
|
||||
|
||||
describe("terminalWebSocketURL", () => {
|
||||
test("uses query auth without embedding credentials in websocket URL", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "http://127.0.0.1:49365",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 0,
|
||||
sameOrigin: false,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("ws:")
|
||||
expect(url.username).toBe("")
|
||||
expect(url.password).toBe("")
|
||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||
})
|
||||
|
||||
test("omits query auth for same-origin saved credentials", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "https://app.example.test",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 10,
|
||||
sameOrigin: true,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("wss:")
|
||||
expect(url.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
|
||||
test("uses query auth for same-origin credentials from auth_token", () => {
|
||||
const url = terminalWebSocketURL({
|
||||
url: "https://app.example.test",
|
||||
id: "pty_test",
|
||||
directory: "/tmp/project",
|
||||
cursor: 10,
|
||||
sameOrigin: true,
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
authToken: true,
|
||||
})
|
||||
|
||||
expect(url.protocol).toBe("wss:")
|
||||
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||
})
|
||||
})
|
||||
23
packages/app/src/utils/terminal-websocket-url.ts
Normal file
23
packages/app/src/utils/terminal-websocket-url.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { authTokenFromCredentials } from "@/utils/server"
|
||||
|
||||
export function terminalWebSocketURL(input: {
|
||||
url: string
|
||||
id: string
|
||||
directory: string
|
||||
cursor: number
|
||||
sameOrigin: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authToken?: boolean
|
||||
}) {
|
||||
const next = new URL(`${input.url}/pty/${input.id}/connect`)
|
||||
next.searchParams.set("directory", input.directory)
|
||||
next.searchParams.set("cursor", String(input.cursor))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (input.password && (!input.sameOrigin || input.authToken))
|
||||
next.searchParams.set(
|
||||
"auth_token",
|
||||
authTokenFromCredentials({ username: input.username, password: input.password }),
|
||||
)
|
||||
return next
|
||||
}
|
||||
@@ -37,6 +37,11 @@
|
||||
"bun": "./src/server/adapter.bun.ts",
|
||||
"node": "./src/server/adapter.node.ts",
|
||||
"default": "./src/server/adapter.bun.ts"
|
||||
},
|
||||
"#httpapi-server": {
|
||||
"bun": "./src/server/httpapi-server.node.ts",
|
||||
"node": "./src/server/httpapi-server.node.ts",
|
||||
"default": "./src/server/httpapi-server.node.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Auth } from "../../auth"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { CliError, effectCmd, fail } from "../effect-cmd"
|
||||
import { UI } from "../ui"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
@@ -14,44 +13,57 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { Plugin } from "../../plugin"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "@/util/process"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
const put = (key: string, info: Auth.Info) =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(key, info)
|
||||
}),
|
||||
)
|
||||
const promptValue = <Value>(value: Option.Option<Value>) => {
|
||||
if (Option.isNone(value)) return Effect.die(new UI.CancelledError())
|
||||
return Effect.succeed(value.value)
|
||||
}
|
||||
|
||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
|
||||
let index = 0
|
||||
if (methodName) {
|
||||
const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) {
|
||||
const auth = yield* Auth.Service
|
||||
yield* Effect.orDie(auth.set(key, info))
|
||||
})
|
||||
|
||||
const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) =>
|
||||
Effect.tryPromise({
|
||||
try: fn,
|
||||
catch: (error) => new CliError({ message: message + errorMessage(error) }),
|
||||
})
|
||||
|
||||
const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
plugin: { auth: PluginAuth },
|
||||
provider: string,
|
||||
methodName?: string,
|
||||
) {
|
||||
const index = yield* Effect.gen(function* () {
|
||||
if (!methodName) {
|
||||
if (plugin.auth.methods.length <= 1) return 0
|
||||
return yield* promptValue(
|
||||
yield* Prompt.select({
|
||||
message: "Login method",
|
||||
options: plugin.auth.methods.map((x, index) => ({
|
||||
label: x.label,
|
||||
value: index,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
|
||||
if (match === -1) {
|
||||
prompts.log.error(
|
||||
return yield* fail(
|
||||
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
index = match
|
||||
} else if (plugin.auth.methods.length > 1) {
|
||||
const method = await prompts.select({
|
||||
message: "Login method",
|
||||
options: plugin.auth.methods.map((x, index) => ({
|
||||
label: x.label,
|
||||
value: index.toString(),
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
index = parseInt(method)
|
||||
}
|
||||
return match
|
||||
})
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
yield* Effect.sleep("10 millis")
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
@@ -63,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
if (prompt.condition && !prompt.condition(inputs)) continue
|
||||
if (prompt.type === "select") {
|
||||
const value = await prompts.select({
|
||||
const value = yield* Prompt.select({
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
})
|
||||
if (prompts.isCancel(value)) throw new UI.CancelledError()
|
||||
inputs[prompt.key] = value
|
||||
} else {
|
||||
const value = await prompts.text({
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
|
||||
})
|
||||
if (prompts.isCancel(value)) throw new UI.CancelledError()
|
||||
inputs[prompt.key] = value
|
||||
inputs[prompt.key] = yield* promptValue(value)
|
||||
continue
|
||||
}
|
||||
const value = yield* Prompt.text({
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
|
||||
})
|
||||
inputs[prompt.key] = yield* promptValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
const authorize = await method.authorize(inputs)
|
||||
const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs))
|
||||
|
||||
if (authorize.url) {
|
||||
prompts.log.info("Go to: " + authorize.url)
|
||||
yield* Prompt.log.info("Go to: " + authorize.url)
|
||||
}
|
||||
|
||||
if (authorize.method === "auto") {
|
||||
if (authorize.instructions) {
|
||||
prompts.log.info(authorize.instructions)
|
||||
yield* Prompt.log.info(authorize.instructions)
|
||||
}
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Waiting for authorization...")
|
||||
const result = await authorize.callback()
|
||||
const spinner = Prompt.spinner()
|
||||
yield* spinner.start("Waiting for authorization...")
|
||||
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback())
|
||||
if (result.type === "failed") {
|
||||
spinner.stop("Failed to authorize", 1)
|
||||
yield* spinner.stop("Failed to authorize", 1)
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await put(saveProvider, {
|
||||
yield* put(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -111,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await put(saveProvider, {
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
spinner.stop("Login successful")
|
||||
yield* spinner.stop("Login successful")
|
||||
}
|
||||
}
|
||||
|
||||
if (authorize.method === "code") {
|
||||
const code = await prompts.text({
|
||||
const code = yield* Prompt.text({
|
||||
message: "Paste the authorization code here: ",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(code)) throw new UI.CancelledError()
|
||||
const result = await authorize.callback(code)
|
||||
const authorizationCode = yield* promptValue(code)
|
||||
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode))
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
yield* Prompt.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await put(saveProvider, {
|
||||
yield* put(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -143,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await put(saveProvider, {
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
}
|
||||
prompts.log.success("Login successful")
|
||||
yield* Prompt.log.success("Login successful")
|
||||
}
|
||||
}
|
||||
|
||||
prompts.outro("Done")
|
||||
yield* Prompt.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
const key = await prompts.password({
|
||||
const key = yield* Prompt.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
const apiKey = yield* promptValue(key)
|
||||
|
||||
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
|
||||
if (!method.authorize) {
|
||||
await put(provider, {
|
||||
const authorizeApi = method.authorize
|
||||
if (!authorizeApi) {
|
||||
yield* put(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
key: apiKey,
|
||||
...metadata,
|
||||
})
|
||||
prompts.outro("Done")
|
||||
yield* Prompt.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await method.authorize(inputs)
|
||||
const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs))
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
yield* Prompt.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await put(saveProvider, {
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? key,
|
||||
key: result.key ?? apiKey,
|
||||
...metadata,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
yield* Prompt.log.success("Login successful")
|
||||
}
|
||||
prompts.outro("Done")
|
||||
yield* Prompt.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
export function resolvePluginProviders(input: {
|
||||
hooks: Hooks[]
|
||||
@@ -244,16 +255,16 @@ export const ProvidersListCommand = effectCmd({
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
const database = yield* modelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
yield* Prompt.outro(`${results.length} credentials`)
|
||||
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
@@ -270,13 +281,13 @@ export const ProvidersListCommand = effectCmd({
|
||||
|
||||
if (activeEnvVars.length > 0) {
|
||||
UI.empty()
|
||||
prompts.intro("Environment")
|
||||
yield* Prompt.intro("Environment")
|
||||
|
||||
for (const { provider, envVar } of activeEnvVars) {
|
||||
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||
yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -301,36 +312,42 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
type: "string",
|
||||
}),
|
||||
handler: Effect.fn("Cli.providers.login")(function* (args) {
|
||||
const cfgSvc = yield* Config.Service
|
||||
const pluginSvc = yield* Plugin.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
yield* Prompt.intro("Add credential")
|
||||
if (args.url) {
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as {
|
||||
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
|
||||
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
|
||||
)) as {
|
||||
auth: { command: string[]; env: string }
|
||||
}
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" })
|
||||
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const abort = new AbortController()
|
||||
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
|
||||
if (!proc.stdout) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
yield* Prompt.log.error("Failed")
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)]))
|
||||
const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
|
||||
Promise.all([proc.exited, text(proc.stdout!)]),
|
||||
).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
|
||||
if (exit !== 0) {
|
||||
prompts.log.error("Failed")
|
||||
prompts.outro("Done")
|
||||
yield* Prompt.log.error("Failed")
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
|
||||
prompts.log.success("Logged into " + url)
|
||||
prompts.outro("Done")
|
||||
yield* Prompt.log.success("Logged into " + url)
|
||||
yield* Prompt.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const cfgSvc = yield* Config.Service
|
||||
const pluginSvc = yield* Plugin.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
yield* Effect.ignore(modelsDev.refresh(true))
|
||||
|
||||
const config = yield* cfgSvc.get()
|
||||
@@ -392,53 +409,46 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
prompts.log.error(`Unknown provider "${input}"`)
|
||||
process.exit(1)
|
||||
return yield* fail(`Unknown provider "${input}"`)
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.autocomplete({
|
||||
provider = yield* promptValue(
|
||||
yield* Prompt.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [...options, { value: "other", label: "Other" }],
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
|
||||
provider = selected as string
|
||||
}
|
||||
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method))
|
||||
const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
const custom = yield* Effect.promise(() =>
|
||||
prompts.text({
|
||||
provider = (yield* promptValue(
|
||||
yield* Prompt.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError())
|
||||
provider = (custom as string).replace(/^@ai-sdk\//, "")
|
||||
)).replace(/^@ai-sdk\//, "")
|
||||
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = yield* Effect.promise(() =>
|
||||
handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method),
|
||||
)
|
||||
const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
prompts.log.warn(
|
||||
yield* Prompt.log.warn(
|
||||
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
yield* Prompt.log.info(
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
|
||||
@@ -448,29 +458,27 @@ export const ProvidersLoginCommand = effectCmd({
|
||||
}
|
||||
|
||||
if (provider === "opencode") {
|
||||
prompts.log.info("Create an api key at https://opencode.ai/auth")
|
||||
yield* Prompt.log.info("Create an api key at https://opencode.ai/auth")
|
||||
}
|
||||
|
||||
if (provider === "vercel") {
|
||||
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
|
||||
}
|
||||
|
||||
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
|
||||
prompts.log.info(
|
||||
yield* Prompt.log.info(
|
||||
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
|
||||
)
|
||||
}
|
||||
|
||||
const key = yield* Effect.promise(() =>
|
||||
prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError())
|
||||
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string }))
|
||||
const key = yield* Prompt.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
const apiKey = yield* promptValue(key)
|
||||
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }))
|
||||
|
||||
prompts.outro("Done")
|
||||
yield* Prompt.outro("Done")
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -485,24 +493,20 @@ export const ProvidersLogoutCommand = effectCmd({
|
||||
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
|
||||
prompts.intro("Remove credential")
|
||||
yield* Prompt.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
yield* Prompt.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = yield* modelsDev.get()
|
||||
const selected = yield* Effect.promise(() =>
|
||||
prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
|
||||
const providerID = selected as string
|
||||
yield* Effect.orDie(authSvc.remove(providerID))
|
||||
prompts.outro("Logout successful")
|
||||
const selected = yield* Prompt.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
|
||||
yield* Prompt.outro("Logout successful")
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -276,6 +276,11 @@ export const RunCommand = effectCmd({
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
})
|
||||
.option("username", {
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
describe: "directory to run in, path on remote server if attaching",
|
||||
@@ -657,7 +662,7 @@ export const RunCommand = effectCmd({
|
||||
}
|
||||
|
||||
if (args.attach) {
|
||||
const headers = ServerAuth.headers({ password: args.password })
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
@@ -779,6 +779,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("clear_prompt_save_history", false) ? "Don't include cleared prompts in history" : "Include cleared prompts in history",
|
||||
value: "app.toggle.clear_prompt_history",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("clear_prompt_save_history", !kv.get("clear_prompt_save_history", false))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
|
||||
@@ -82,6 +82,7 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
if (store.history.at(-1)?.input === item.input) return
|
||||
const entry = structuredClone(unwrap(item))
|
||||
let trimmed = false
|
||||
setStore(
|
||||
|
||||
@@ -136,6 +136,7 @@ export function Prompt(props: PromptProps) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax } = useTheme()
|
||||
const kv = useKV()
|
||||
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
@@ -296,6 +297,17 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
|
||||
value: "permission.auto_accept.toggle",
|
||||
search: "toggle permissions",
|
||||
keybind: "permission_auto_accept_toggle",
|
||||
category: "Agent",
|
||||
onSelect: (dialog) => {
|
||||
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
@@ -1124,6 +1136,12 @@ export function Prompt(props: PromptProps) {
|
||||
// If no image, let the default paste behavior continue
|
||||
}
|
||||
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||
if (kv.get("clear_prompt_save_history", false)) {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
}
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
@@ -1316,8 +1334,13 @@ export function Prompt(props: PromptProps) {
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={hasRightContent()}>
|
||||
<Show when={hasRightContent() || autoaccept() === "edit"}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<Show when={autoaccept() === "edit"}>
|
||||
<text>
|
||||
<span style={{ fg: theme.warning }}>autoedit</span>
|
||||
</text>
|
||||
</Show>
|
||||
{props.right}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
if (index < 0) return
|
||||
const assistant = messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
}
|
||||
|
||||
function activeCompaction(messages: SessionMessage[]) {
|
||||
const index = messages.findIndex((message) => message.type === "compaction")
|
||||
const index = messages.findLastIndex((message) => message.type === "compaction")
|
||||
if (index < 0) return
|
||||
const compaction = messages[index]
|
||||
return compaction?.type === "compaction" ? compaction : undefined
|
||||
}
|
||||
|
||||
function activeShell(messages: SessionMessage[], callID: string) {
|
||||
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
if (index < 0) return
|
||||
const shell = messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
switch (event.type) {
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
}
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
draft.unshift({
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||
break
|
||||
case "session.next.compaction.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
draft.unshift({
|
||||
draft.push({
|
||||
id: event.id,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
|
||||
@@ -110,6 +110,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
|
||||
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
@@ -152,6 +153,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
|
||||
case "permission.asked": {
|
||||
const request = event.properties
|
||||
if (autoaccept() === "edit" && request.permission === "edit") {
|
||||
void sdk.client.permission.reply({
|
||||
reply: "once",
|
||||
requestID: request.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
const requests = store.permission[request.sessionID]
|
||||
if (!requests) {
|
||||
setStore("permission", request.sessionID, [request])
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { BoxRenderable, SyntaxStyle } from "@opentui/core"
|
||||
import type { SyntaxStyle } from "@opentui/core"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import path from "path"
|
||||
@@ -44,10 +44,6 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
|
||||
const renderedMessages = createMemo(() => messages().toReversed())
|
||||
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
|
||||
const lastUserCreated = (index: number) =>
|
||||
renderedMessages()
|
||||
.slice(0, index)
|
||||
.findLast((message) => message.type === "user")?.time.created
|
||||
|
||||
createEffect(() => {
|
||||
void sync.session.message.sync(props.sessionID)
|
||||
@@ -87,7 +83,6 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
last={lastAssistant()?.id === message.id}
|
||||
syntax={syntax()}
|
||||
subtleSyntax={subtleSyntax()}
|
||||
start={lastUserCreated(index())}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={message.type === "synthetic"}>
|
||||
@@ -299,13 +294,12 @@ function AssistantMessage(props: {
|
||||
last: boolean
|
||||
syntax: SyntaxStyle
|
||||
subtleSyntax: SyntaxStyle
|
||||
start?: number
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const local = useLocal()
|
||||
const duration = createMemo(() => {
|
||||
if (!props.message.time.completed) return 0
|
||||
return props.message.time.completed - (props.start ?? props.message.time.created)
|
||||
return props.message.time.completed - props.message.time.created
|
||||
})
|
||||
const model = createMemo(() => {
|
||||
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
|
||||
@@ -527,7 +521,6 @@ function InlineTool(props: {
|
||||
part: SessionMessageAssistantTool
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const [margin, setMargin] = createSignal(0)
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
const denied = createMemo(() => {
|
||||
const message = error()
|
||||
@@ -539,46 +532,21 @@ function InlineTool(props: {
|
||||
)
|
||||
})
|
||||
return (
|
||||
<box
|
||||
marginTop={margin()}
|
||||
paddingLeft={3}
|
||||
flexShrink={0}
|
||||
renderBefore={function () {
|
||||
const el = this as BoxRenderable
|
||||
const parent = el.parent
|
||||
if (!parent) return
|
||||
if (el.height > 1) {
|
||||
setMargin(1)
|
||||
return
|
||||
}
|
||||
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
|
||||
if (!previous) {
|
||||
setMargin(0)
|
||||
return
|
||||
}
|
||||
if (previous.height > 1 || previous.id.startsWith("text-")) setMargin(1)
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box width={3} flexShrink={0}>
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={<text fg={props.complete ? theme.textMuted : theme.text}>{props.complete ? props.icon : "~"}</text>}
|
||||
>
|
||||
<Spinner color={theme.text} />
|
||||
</Show>
|
||||
</box>
|
||||
<text fg={props.complete ? theme.textMuted : theme.text}>
|
||||
<Show fallback={props.pending} when={props.complete || props.spinner}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<box marginTop={1} paddingLeft={3} flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={props.spinner}>
|
||||
<Spinner color={theme.text}>{props.children}</Spinner>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
|
||||
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
|
||||
{props.icon} {props.children}
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={error() && !denied()}>
|
||||
<box flexDirection="row">
|
||||
<box width={3} flexShrink={0} />
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</box>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { UI } from "@/cli/ui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
@@ -190,7 +191,11 @@ export const TuiThreadCommand = cmd({
|
||||
const prompt = await input(args.prompt)
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const network = resolveNetworkOptionsNoConfig(args)
|
||||
const network = await WithInstance.provide({
|
||||
directory: cwd,
|
||||
fn: () => resolveNetworkOptionsNoConfig(args),
|
||||
})
|
||||
|
||||
const external =
|
||||
process.argv.includes("--port") ||
|
||||
process.argv.includes("--hostname") ||
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
search?: string
|
||||
footer?: JSX.Element | string
|
||||
category?: string
|
||||
categoryView?: JSX.Element
|
||||
@@ -93,8 +94,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
// users typically search by the item name, and not its category.
|
||||
const result = fuzzysort
|
||||
.go(needle, options, {
|
||||
keys: ["title", "category"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score,
|
||||
keys: ["title", "category", "search"],
|
||||
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
|
||||
})
|
||||
.map((x) => x.obj)
|
||||
|
||||
|
||||
@@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
|
||||
|
||||
export const log = {
|
||||
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
|
||||
error: (msg: string) => Effect.sync(() => prompts.log.error(msg)),
|
||||
warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)),
|
||||
success: (msg: string) => Effect.sync(() => prompts.log.success(msg)),
|
||||
}
|
||||
|
||||
const optional = <Value>(result: Value | symbol) => {
|
||||
if (prompts.isCancel(result)) return Option.none<Value>()
|
||||
return Option.some(result)
|
||||
}
|
||||
|
||||
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
|
||||
Effect.tryPromise(() => prompts.select(opts)).pipe(
|
||||
Effect.map((result) => {
|
||||
if (prompts.isCancel(result)) return Option.none<Value>()
|
||||
return Option.some(result)
|
||||
}),
|
||||
)
|
||||
Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result)))
|
||||
|
||||
export const autocomplete = <Value>(opts: Parameters<typeof prompts.autocomplete<Value>>[0]) =>
|
||||
Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result)))
|
||||
|
||||
export const text = (opts: Parameters<typeof prompts.text>[0]) =>
|
||||
Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result)))
|
||||
|
||||
export const password = (opts: Parameters<typeof prompts.password>[0]) =>
|
||||
Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result)))
|
||||
|
||||
export const spinner = () => {
|
||||
const s = prompts.spinner()
|
||||
|
||||
@@ -47,19 +47,37 @@ import { Workspace } from "@/control-plane/workspace"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
// Adjusts the default Config layer to ensure that plugins are always initialised before
|
||||
// any other layers read the current config
|
||||
const ConfigWithPluginPriority = Layer.effect(
|
||||
Config.Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
return {
|
||||
...config,
|
||||
get: () => Effect.andThen(plugin.init(), config.get),
|
||||
getGlobal: () => Effect.andThen(plugin.init(), config.getGlobal),
|
||||
getConsoleState: () => Effect.andThen(plugin.init(), config.getConsoleState),
|
||||
}
|
||||
}),
|
||||
).pipe(Layer.provide(Layer.merge(Plugin.defaultLayer, Config.defaultLayer)))
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
ConfigWithPluginPriority,
|
||||
Git.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
File.defaultLayer,
|
||||
|
||||
@@ -14,7 +14,14 @@ const ISSUER = "https://auth.openai.com"
|
||||
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
||||
const OAUTH_PORT = 1455
|
||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
|
||||
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"])
|
||||
const ALLOWED_MODELS = new Set([
|
||||
"gpt-5.5",
|
||||
"gpt-5.2",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.3-codex-spark",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
])
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Plugin } from "../plugin"
|
||||
import { Format } from "../format"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { File } from "../file"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import * as Project from "./project"
|
||||
import * as Vcs from "./vcs"
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library,
|
||||
// and `node:http`'s `upgrade` event.
|
||||
//
|
||||
// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that
|
||||
// drives the experimental HttpApi handler directly (no Hono in the middle) and handles
|
||||
// WebSocket upgrades inline based on path-matching. It exists to validate the pattern
|
||||
// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it.
|
||||
|
||||
import type { ServerWebSocket } from "bun"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { Pty } from "@/pty"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty"
|
||||
import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { CorsOptions } from "./cors"
|
||||
|
||||
const log = Log.create({ service: "httpapi-listener" })
|
||||
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
||||
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export type ListenOptions = CorsOptions & {
|
||||
port: number
|
||||
hostname: string
|
||||
}
|
||||
|
||||
type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string }
|
||||
|
||||
type PtyHandler = {
|
||||
onMessage: (message: string | ArrayBuffer) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type WsState = WsKind & {
|
||||
handler?: PtyHandler
|
||||
pending: Array<string | Uint8Array>
|
||||
ready: boolean
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
// Derive from the OpenAPI path so this stays in sync if the route literal moves.
|
||||
const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`)
|
||||
|
||||
function parseCursor(value: string | null): number | undefined {
|
||||
if (!value) return undefined
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined
|
||||
return parsed
|
||||
}
|
||||
|
||||
function asAdapter(ws: ServerWebSocket<WsState>) {
|
||||
return {
|
||||
get readyState() {
|
||||
return ws.readyState
|
||||
},
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => {
|
||||
try {
|
||||
if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data))
|
||||
else ws.send(data)
|
||||
} catch {
|
||||
// socket likely already closed; ignore
|
||||
}
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
try {
|
||||
ws.close(code, reason)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spin up a native Bun.serve that:
|
||||
* 1. Routes all HTTP traffic through the HttpApi web handler.
|
||||
* 2. Intercepts known WebSocket upgrade paths and handles them inline.
|
||||
*
|
||||
* This bypasses Hono entirely. The Hono code path remains untouched.
|
||||
*/
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
const built = ExperimentalHttpApiServer.webHandler(opts)
|
||||
const handler = built.handler
|
||||
const context = ExperimentalHttpApiServer.context
|
||||
|
||||
const start = (port: number) => {
|
||||
try {
|
||||
return Bun.serve<WsState>({
|
||||
hostname: opts.hostname,
|
||||
port,
|
||||
idleTimeout: 0,
|
||||
fetch(request, server) {
|
||||
const url = new URL(request.url)
|
||||
const ptyMatch = url.pathname.match(ptyConnectPattern)
|
||||
if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||
const ptyID = ptyMatch[1]!
|
||||
const cursor = parseCursor(url.searchParams.get("cursor"))
|
||||
// Resolve the instance directory the same way the HttpApi
|
||||
// `instance-context` middleware does (search params, then header,
|
||||
// then process.cwd()).
|
||||
const directory =
|
||||
url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd()
|
||||
const upgraded = server.upgrade(request, {
|
||||
data: {
|
||||
kind: "pty",
|
||||
ptyID,
|
||||
cursor,
|
||||
directory,
|
||||
pending: [],
|
||||
ready: false,
|
||||
closed: false,
|
||||
} satisfies WsState,
|
||||
})
|
||||
if (upgraded) return undefined
|
||||
return new Response("upgrade failed", { status: 400 })
|
||||
}
|
||||
|
||||
// TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a
|
||||
// remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support
|
||||
// that here we'd need to (a) resolve the workspace target the same way
|
||||
// `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request,
|
||||
// { data: { kind: "proxy", target, headers, protocols } })` and bridge the
|
||||
// ServerWebSocket to a remote WebSocket inside the `websocket` handlers.
|
||||
// Deferred to a follow-up — the proxy story needs more design (auth header
|
||||
// forwarding, fence sync, reconnection semantics) than fits this PR.
|
||||
|
||||
return handler(request as Request, context as never)
|
||||
},
|
||||
websocket: {
|
||||
open(ws) {
|
||||
const data = ws.data
|
||||
if (data.kind !== "pty") {
|
||||
ws.close(1011, "unknown ws kind")
|
||||
return
|
||||
}
|
||||
const id = (() => {
|
||||
try {
|
||||
return decodePtyID(data.ptyID)
|
||||
} catch {
|
||||
ws.close(1008, "invalid pty id")
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!id) return
|
||||
;(async () => {
|
||||
const result = await WithInstance.provide({
|
||||
directory: data.directory,
|
||||
fn: () =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.connect(id, asAdapter(ws), data.cursor)
|
||||
}).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")),
|
||||
),
|
||||
})
|
||||
return await result
|
||||
})()
|
||||
.then((handler) => {
|
||||
if (data.closed) {
|
||||
handler?.onClose()
|
||||
return
|
||||
}
|
||||
if (!handler) {
|
||||
ws.close(4404, "session not found")
|
||||
return
|
||||
}
|
||||
data.handler = handler
|
||||
data.ready = true
|
||||
for (const msg of data.pending) {
|
||||
AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined)
|
||||
}
|
||||
data.pending.length = 0
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("pty connect failed", { error: err })
|
||||
ws.close(1011, "pty connect failed")
|
||||
})
|
||||
},
|
||||
message(ws, message) {
|
||||
const data = ws.data
|
||||
if (data.kind !== "pty") return
|
||||
const payload =
|
||||
typeof message === "string"
|
||||
? message
|
||||
: message instanceof Buffer
|
||||
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
|
||||
: (message as Uint8Array)
|
||||
if (!data.ready || !data.handler) {
|
||||
data.pending.push(payload)
|
||||
return
|
||||
}
|
||||
AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined)
|
||||
},
|
||||
close(ws) {
|
||||
const data = ws.data
|
||||
data.closed = true
|
||||
data.handler?.onClose()
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
log.error("Bun.serve failed", { error: err })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
const port = server.port
|
||||
if (port === undefined) throw new Error("Bun.serve started without a numeric port")
|
||||
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = opts.hostname
|
||||
url.port = String(port)
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port,
|
||||
url,
|
||||
stop(close?: boolean) {
|
||||
closing ??= (async () => {
|
||||
await server.stop(close)
|
||||
// NOTE: we deliberately do NOT call `built.dispose()` here. The
|
||||
// underlying `webHandler` is memoized at module level (same as the
|
||||
// Hono path), so disposing it would tear down shared services for
|
||||
// every other consumer in the process. Lifecycle teardown is owned
|
||||
// by the AppRuntime itself.
|
||||
})()
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as HttpApiListener from "./httpapi-listener"
|
||||
34
packages/opencode/src/server/httpapi-server.node.ts
Normal file
34
packages/opencode/src/server/httpapi-server.node.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { createServer } from "node:http"
|
||||
import type { Opts } from "./adapter"
|
||||
import { Service } from "./httpapi-server"
|
||||
|
||||
export { Service }
|
||||
|
||||
export const name = "node-http-server"
|
||||
|
||||
export const layer = (opts: Opts) => {
|
||||
const server = createServer()
|
||||
const serverRef = { closeStarted: false, forceStop: false }
|
||||
const close = server.close.bind(server)
|
||||
// Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by
|
||||
// force-closing active HTTP sockets when its finalizer calls server.close().
|
||||
server.close = ((callback?: Parameters<typeof server.close>[0]) => {
|
||||
serverRef.closeStarted = true
|
||||
const result = close(callback)
|
||||
if (serverRef.forceStop) server.closeAllConnections()
|
||||
return result
|
||||
}) as typeof server.close
|
||||
return Layer.mergeAll(
|
||||
NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }),
|
||||
Layer.succeed(Service)(
|
||||
Service.of({
|
||||
closeAll: Effect.sync(() => {
|
||||
serverRef.forceStop = true
|
||||
if (serverRef.closeStarted) server.closeAllConnections()
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
9
packages/opencode/src/server/httpapi-server.ts
Normal file
9
packages/opencode/src/server/httpapi-server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
export interface Interface {
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiServer") {}
|
||||
|
||||
export * as HttpApiServer from "./httpapi-server"
|
||||
@@ -2,12 +2,14 @@ import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { InstanceHttpApi } from "../api"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -80,9 +82,22 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||
const write = yield* socket.writer
|
||||
const services = yield* Effect.context()
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
|
||||
.pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT()))
|
||||
if (!registered) {
|
||||
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
|
||||
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
|
||||
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
|
||||
}
|
||||
let closed = false
|
||||
const adapter = {
|
||||
@@ -100,7 +115,10 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
},
|
||||
}
|
||||
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
|
||||
if (!handler) return HttpServerResponse.empty()
|
||||
if (!handler) {
|
||||
yield* closeAccepted(new Socket.CloseEvent(4404, "session not found"))
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
|
||||
yield* socket
|
||||
.runRaw((message) => handlePtyInput(handler, message))
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
const AUTH_TOKEN_QUERY = "auth_token"
|
||||
const UNAUTHORIZED = 401
|
||||
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
|
||||
|
||||
// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the
|
||||
// full handler, so a downstream failure can make the next auth alternative run
|
||||
// and remap an authorized NotFound into Unauthorized.
|
||||
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
|
||||
"@opencode/ExperimentalHttpApiAuthorization",
|
||||
{
|
||||
error: HttpApiError.UnauthorizedNoContent,
|
||||
security: {
|
||||
basic: HttpApiSecurity.basic,
|
||||
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
|
||||
},
|
||||
},
|
||||
) {}
|
||||
|
||||
function emptyCredential() {
|
||||
return {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
}
|
||||
|
||||
function validateCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: ServerAuth.DecodedCredentials,
|
||||
@@ -31,19 +37,14 @@ function validateCredential<A, E, R>(
|
||||
}
|
||||
|
||||
function decodeCredential(input: string) {
|
||||
const emptyCredential = {
|
||||
username: "",
|
||||
password: Redacted.make(""),
|
||||
}
|
||||
|
||||
return Encoding.decodeBase64String(input)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
Effect.match({
|
||||
onFailure: () => emptyCredential,
|
||||
onFailure: emptyCredential,
|
||||
onSuccess: (header) => {
|
||||
const parts = header.split(":")
|
||||
if (parts.length !== 2) return emptyCredential
|
||||
if (parts.length !== 2) return emptyCredential()
|
||||
return {
|
||||
username: parts[0],
|
||||
password: Redacted.make(parts[1]),
|
||||
@@ -53,6 +54,14 @@ function decodeCredential(input: string) {
|
||||
)
|
||||
}
|
||||
|
||||
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
|
||||
if (token) return decodeCredential(token)
|
||||
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
|
||||
if (match) return decodeCredential(match[1])
|
||||
return Effect.succeed(emptyCredential())
|
||||
}
|
||||
|
||||
function validateRawCredential<A, E, R>(
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
credential: ServerAuth.DecodedCredentials,
|
||||
@@ -77,21 +86,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
|
||||
if (match) {
|
||||
return yield* decodeCredential(match[1]).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
}
|
||||
|
||||
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
|
||||
if (token) {
|
||||
return yield* decodeCredential(token).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
}
|
||||
|
||||
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -100,12 +97,14 @@ export const authorizationLayer = Layer.effect(
|
||||
Authorization,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* ServerAuth.Config
|
||||
return Authorization.of({
|
||||
basic: (effect, { credential }) => validateCredential(effect, credential, config),
|
||||
authToken: (effect, { credential }) =>
|
||||
decodeCredential(Redacted.value(credential)).pipe(
|
||||
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
|
||||
),
|
||||
})
|
||||
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
|
||||
return Authorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
|
||||
return request.source instanceof Request ? request.source : undefined
|
||||
@@ -28,6 +29,33 @@ export function websocket(
|
||||
})
|
||||
const writeInbound = yield* inbound.writer
|
||||
const writeOutbound = yield* outbound.writer
|
||||
const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect<void, unknown>) =>
|
||||
socket
|
||||
.runRaw(() => Effect.void, {
|
||||
onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)),
|
||||
})
|
||||
.pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
const registered = yield* WebSocketTracker.register(
|
||||
Effect.all(
|
||||
[
|
||||
writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
|
||||
writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
|
||||
],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
),
|
||||
)
|
||||
if (!registered) {
|
||||
yield* closeAccepted
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
|
||||
yield* outbound
|
||||
.runRaw((message) => writeInbound(message))
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
|
||||
export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing")
|
||||
|
||||
type Close = Effect.Effect<void, unknown>
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (close: Close) => Effect.Effect<boolean>
|
||||
readonly remove: (close: Close) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiWebSocketTracker") {}
|
||||
|
||||
export const layer = Layer.sync(Service)(() => {
|
||||
const sockets = new Set<Close>()
|
||||
let closing = false
|
||||
return Service.of({
|
||||
add: (close) =>
|
||||
Effect.gen(function* () {
|
||||
if (closing) return false
|
||||
sockets.add(close)
|
||||
return true
|
||||
}),
|
||||
remove: (close) =>
|
||||
Effect.sync(() => {
|
||||
sockets.delete(close)
|
||||
}),
|
||||
closeAll: Effect.gen(function* () {
|
||||
closing = true
|
||||
const active = Array.from(sockets)
|
||||
sockets.clear()
|
||||
yield* Effect.all(
|
||||
active.map((close) =>
|
||||
close.pipe(
|
||||
Effect.timeout("1 second"),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const register = (close: Close) =>
|
||||
Effect.gen(function* () {
|
||||
const tracker = yield* Effect.serviceOption(Service)
|
||||
if (Option.isNone(tracker)) return true
|
||||
const registered = yield* tracker.value.add(close)
|
||||
if (!registered) return false
|
||||
yield* Effect.addFinalizer(() => tracker.value.remove(close))
|
||||
return true
|
||||
})
|
||||
|
||||
export * as WebSocketTracker from "./websocket-tracker"
|
||||
@@ -5,7 +5,10 @@ import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import * as HttpApiServer from "#httpapi-server"
|
||||
import { MDNS } from "./mdns"
|
||||
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||
import { FenceMiddleware } from "./fence"
|
||||
@@ -18,6 +21,8 @@ import { WorkspaceRouterMiddleware } from "./workspace"
|
||||
import { InstanceMiddleware } from "./routes/instance/middleware"
|
||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
||||
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
|
||||
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
|
||||
import { PublicApi } from "./routes/instance/httpapi/public"
|
||||
import * as ServerBackend from "./backend"
|
||||
import type { CorsOptions } from "./cors"
|
||||
@@ -182,37 +187,145 @@ export async function openapiHono() {
|
||||
export let url: URL
|
||||
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
const selected = select()
|
||||
const inner: Listener =
|
||||
selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts)
|
||||
|
||||
const next = new URL("http://localhost")
|
||||
next.hostname = opts.hostname
|
||||
next.port = String(server.port)
|
||||
const next = new URL(inner.url)
|
||||
url = next
|
||||
|
||||
const mdns =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
|
||||
if (mdns) {
|
||||
MDNS.publish(server.port, opts.mdnsDomain)
|
||||
MDNS.publish(inner.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
let closing: Promise<void> | undefined
|
||||
let mdnsUnpublished = false
|
||||
const unpublish = () => {
|
||||
if (!mdns || mdnsUnpublished) return
|
||||
mdnsUnpublished = true
|
||||
MDNS.unpublish()
|
||||
}
|
||||
return {
|
||||
hostname: inner.hostname,
|
||||
port: inner.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
unpublish()
|
||||
// Always forward stop(true), even if a graceful stop was requested
|
||||
// first, so native listeners can escalate shutdown in-place.
|
||||
const next = inner.stop(close)
|
||||
closing ??= next
|
||||
return close ? next.then(() => closing!) : closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function listenLegacy(opts: ListenOptions): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(server.port)
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: server.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
closing ??= (async () => {
|
||||
if (mdns) MDNS.unpublish()
|
||||
await server.stop(close)
|
||||
})()
|
||||
return closing
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => server.stop(close),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the effect-httpapi backend on a native Effect HTTP server. This
|
||||
* lets HttpApi routes that call `request.upgrade` (PTY connect, the
|
||||
* workspace-routing proxy WS bridge) work end-to-end; the legacy Hono
|
||||
* adapter path can't surface `request.upgrade` because its fetch handler has
|
||||
* no reference to the platform server instance for websocket upgrades.
|
||||
*/
|
||||
async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise<Listener> {
|
||||
log.info("server backend selected", {
|
||||
...ServerBackend.attributes(selection),
|
||||
"opencode.server.runtime": HttpApiServer.name,
|
||||
})
|
||||
|
||||
const buildLayer = (port: number) =>
|
||||
HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), {
|
||||
middleware: disposeMiddleware,
|
||||
disableLogger: true,
|
||||
disableListenLog: true,
|
||||
}).pipe(
|
||||
Layer.provideMerge(WebSocketTracker.layer),
|
||||
Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })),
|
||||
)
|
||||
|
||||
const start = async (port: number) => {
|
||||
const scope = Scope.makeUnsafe()
|
||||
try {
|
||||
// Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by
|
||||
// design, which leaks `R = any` through `HttpRouter.serve`. The actual
|
||||
// requirements at this point are fully satisfied by `createRoutes` and the
|
||||
// platform HTTP server layer; cast away the `any` to satisfy `runPromise`.
|
||||
const layer = buildLayer(port) as Layer.Layer<
|
||||
HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service,
|
||||
unknown,
|
||||
never
|
||||
>
|
||||
const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope))
|
||||
return { scope, ctx }
|
||||
} catch (err) {
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Match the legacy adapter port-resolution behavior: explicit `0` prefers
|
||||
// 4096 first, then any free port.
|
||||
let resolved: Awaited<ReturnType<typeof start>> | undefined
|
||||
if (opts.port === 0) {
|
||||
resolved = await start(4096).catch(() => undefined)
|
||||
if (!resolved) resolved = await start(0)
|
||||
} else {
|
||||
resolved = await start(opts.port)
|
||||
}
|
||||
if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
|
||||
const server = Context.get(resolved.ctx, HttpServer.HttpServer)
|
||||
if (server.address._tag !== "TcpAddress") {
|
||||
await Effect.runPromise(Scope.close(resolved.scope, Exit.void))
|
||||
throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`)
|
||||
}
|
||||
const port = server.address.port
|
||||
|
||||
const innerUrl = new URL("http://localhost")
|
||||
innerUrl.hostname = opts.hostname
|
||||
innerUrl.port = String(port)
|
||||
let forceStopPromise: Promise<void> | undefined
|
||||
let stopPromise: Promise<void> | undefined
|
||||
const forceStop = () => {
|
||||
forceStopPromise ??= Effect.runPromiseExit(
|
||||
Effect.gen(function* () {
|
||||
yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll
|
||||
yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll
|
||||
}),
|
||||
).then(() => undefined)
|
||||
return forceStopPromise
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port,
|
||||
url: innerUrl,
|
||||
stop: (close?: boolean) => {
|
||||
const requested = close ? forceStop() : Promise.resolve()
|
||||
// The first call starts scope shutdown. A later stop(true) cannot undo
|
||||
// that, but it still runs forceStop() before awaiting the original close.
|
||||
stopPromise ??= requested
|
||||
.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void)))
|
||||
.then(() => undefined)
|
||||
return requested.then(() => stopPromise!)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,31 @@ export function embeddedUI() {
|
||||
return embeddedUIPromise
|
||||
}
|
||||
|
||||
function notFound() {
|
||||
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
}
|
||||
|
||||
function embeddedUIResponse(file: string, body: Uint8Array) {
|
||||
const mime = AppFileSystem.mimeType(file)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return HttpServerResponse.raw(body, { headers })
|
||||
}
|
||||
|
||||
export function serveEmbeddedUIEffect(
|
||||
requestPath: string,
|
||||
fs: AppFileSystem.Interface,
|
||||
embeddedWebUI: Record<string, string>,
|
||||
) {
|
||||
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!file) return Effect.succeed(notFound())
|
||||
|
||||
return fs.readFile(file).pipe(
|
||||
Effect.map((body) => embeddedUIResponse(file, body)),
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
|
||||
)
|
||||
}
|
||||
|
||||
export function serveUIEffect(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
|
||||
@@ -53,19 +78,7 @@ export function serveUIEffect(
|
||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
|
||||
const path = new URL(request.url, "http://localhost").pathname
|
||||
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
|
||||
if (yield* services.fs.existsSafe(match)) {
|
||||
const mime = AppFileSystem.mimeType(match)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
|
||||
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
|
||||
}
|
||||
|
||||
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
|
||||
}
|
||||
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI)
|
||||
|
||||
const response = yield* services.client.execute(
|
||||
HttpClientRequest.make(request.method)(upstreamURL(path), {
|
||||
|
||||
@@ -339,7 +339,8 @@ export const Event = {
|
||||
sessionID: Schema.optional(SessionID),
|
||||
// Reuses MessageV2.Assistant.fields.error (already Schema.optional) so
|
||||
// the derived zod keeps the same discriminated-union shape on the bus.
|
||||
error: MessageV2.Assistant.fields.error,
|
||||
// Schema.suspend defers access to break circular init in compiled binaries.
|
||||
error: Schema.suspend(() => MessageV2.Assistant.fields.error),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -256,6 +256,8 @@ function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
return array(ast)
|
||||
case "Declaration":
|
||||
return decl(ast)
|
||||
case "Suspend":
|
||||
return z.lazy(() => walk(ast.thunk()))
|
||||
default:
|
||||
return fail(ast)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string): Promise<T> {
|
||||
let timeout: NodeJS.Timeout
|
||||
return Promise.race([
|
||||
promise.finally(() => {
|
||||
@@ -6,7 +6,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error(`Operation timed out after ${ms}ms`))
|
||||
reject(new Error(label ?? `Operation timed out after ${ms}ms`))
|
||||
}, ms)
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -229,8 +229,8 @@ test("agent permission config merges with defaults", async () => {
|
||||
expect(build).toBeDefined()
|
||||
// Specific pattern is denied
|
||||
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
|
||||
// Edit still allowed
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
// Edit still asks (default behavior)
|
||||
expect(evalPerm(build, "edit")).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +1,65 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { expect } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
const pluginAgent = {
|
||||
name: "plugin_added",
|
||||
description: "Added by a plugin via the config hook",
|
||||
mode: "subagent",
|
||||
} as const
|
||||
|
||||
test("plugin-registered agents appear in Agent.list", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
await Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
"export default async () => ({",
|
||||
" config: async (cfg) => {",
|
||||
" cfg.agent = cfg.agent ?? {}",
|
||||
" cfg.agent.plugin_added = {",
|
||||
' description: "Added by a plugin via the config hook",',
|
||||
' mode: "subagent",',
|
||||
" }",
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
const added = agents.find((agent) => agent.name === "plugin_added")
|
||||
expect(added?.description).toBe("Added by a plugin via the config hook")
|
||||
expect(added?.mode).toBe("subagent")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.live("plugin-registered agents appear in Agent.list", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
"export default async () => ({",
|
||||
" config: async (cfg) => {",
|
||||
" cfg.agent = cfg.agent ?? {}",
|
||||
` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`,
|
||||
` description: ${JSON.stringify(pluginAgent.description)},`,
|
||||
` mode: ${JSON.stringify(pluginAgent.mode)},`,
|
||||
" }",
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
),
|
||||
])
|
||||
})
|
||||
|
||||
const agents = yield* InstanceStore.Service.use((store) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* store.load({ directory: dir })
|
||||
yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore))
|
||||
return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx))
|
||||
}),
|
||||
)
|
||||
const added = agents.find((agent) => agent.name === pluginAgent.name)
|
||||
|
||||
expect(added?.description).toBe(pluginAgent.description)
|
||||
expect(added?.mode).toBe(pluginAgent.mode)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -13,11 +13,19 @@ const Api = HttpApi.make("test-authorization").add(
|
||||
HttpApiEndpoint.get("probe", "/probe", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
HttpApiEndpoint.get("missing", "/missing", {
|
||||
success: Schema.String,
|
||||
error: HttpApiError.NotFound,
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
|
||||
handlers
|
||||
.handle("probe", () => Effect.succeed("ok"))
|
||||
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||
)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
|
||||
@@ -32,8 +40,7 @@ const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
|
||||
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
|
||||
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
|
||||
|
||||
const basic = (username: string, password: string) =>
|
||||
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
|
||||
|
||||
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
|
||||
|
||||
@@ -90,6 +97,35 @@ describe("HttpApi authorization middleware", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("prefers auth token query credentials over basic auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get(
|
||||
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
|
||||
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when basic auth succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get("/missing").pipe(
|
||||
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when auth token query succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("rejects malformed auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
|
||||
|
||||
155
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
155
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
const auth = { username: "opencode", password: "listen-secret" }
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword
|
||||
if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME
|
||||
else process.env.OPENCODE_SERVER_USERNAME = original.envUsername
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
async function startListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
||||
process.env.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
process.env.OPENCODE_SERVER_USERNAME = auth.username
|
||||
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
function authorization() {
|
||||
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
||||
}
|
||||
|
||||
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
||||
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("directory", dir)
|
||||
url.searchParams.set("cursor", "-1")
|
||||
url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`))
|
||||
return url
|
||||
}
|
||||
|
||||
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
|
||||
const response = await fetch(new URL(PtyPaths.create, listener.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: authorization(),
|
||||
"x-opencode-directory": dir,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json()) as { id: string }
|
||||
}
|
||||
|
||||
async function openSocket(url: URL) {
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = "arraybuffer"
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener("open", () => resolve(), { once: true })
|
||||
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket open",
|
||||
)
|
||||
return ws
|
||||
}
|
||||
|
||||
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
|
||||
return withTimeout(listener.stop(true), 10_000, label)
|
||||
}
|
||||
|
||||
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
|
||||
const decoder = new TextDecoder()
|
||||
let onMessage: ((event: MessageEvent) => void) | undefined
|
||||
return withTimeout(
|
||||
new Promise<string>((resolve) => {
|
||||
onMessage = (event: MessageEvent) => {
|
||||
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
|
||||
if (!predicate(message)) return
|
||||
resolve(message)
|
||||
}
|
||||
ws.addEventListener("message", onMessage)
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket message",
|
||||
).finally(() => {
|
||||
if (onMessage) ws.removeEventListener("message", onMessage)
|
||||
})
|
||||
}
|
||||
|
||||
describe("HttpApi Server.listen", () => {
|
||||
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
let stopped = false
|
||||
try {
|
||||
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
|
||||
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: expect.any(String),
|
||||
name: expect.any(String),
|
||||
acceptable: expect.any(Boolean),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
||||
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
|
||||
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
|
||||
ws.send("ping-listen\n")
|
||||
expect(await message).toContain("ping-listen")
|
||||
|
||||
await stop(listener, "timed out waiting for listener.stop(true)")
|
||||
stopped = true
|
||||
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
|
||||
expect(ws.readyState).toBe(WebSocket.CLOSED)
|
||||
|
||||
const restarted = await startListener()
|
||||
try {
|
||||
const nextInfo = await createCat(restarted, tmp.path)
|
||||
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path))
|
||||
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
|
||||
nextWs.send("ping-restarted\n")
|
||||
expect(await nextMessage).toContain("ping-restarted")
|
||||
nextWs.close(1000)
|
||||
} finally {
|
||||
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
|
||||
}
|
||||
} finally {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { HttpApiListener } from "../../src/server/httpapi-listener"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
async function startListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
describe("native HttpApi listener", () => {
|
||||
test("serves HTTP routes via the HttpApi web handler", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
try {
|
||||
const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body[0]).toMatchObject({
|
||||
path: expect.any(String),
|
||||
name: expect.any(String),
|
||||
acceptable: expect.any(Boolean),
|
||||
})
|
||||
} finally {
|
||||
await listener.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("PTY websocket connect echoes input back to the client", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
try {
|
||||
const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const info = (await created.json()) as { id: string }
|
||||
|
||||
try {
|
||||
const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url)
|
||||
wsURL.protocol = "ws:"
|
||||
wsURL.searchParams.set("directory", tmp.path)
|
||||
wsURL.searchParams.set("cursor", "-1")
|
||||
|
||||
const messages: string[] = []
|
||||
const ws = new WebSocket(wsURL)
|
||||
ws.binaryType = "arraybuffer"
|
||||
|
||||
const opened = new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener("open", () => resolve(), { once: true })
|
||||
ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true })
|
||||
})
|
||||
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
ws.addEventListener("close", () => resolve(), { once: true })
|
||||
})
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
const data = event.data
|
||||
messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer))
|
||||
})
|
||||
|
||||
await opened
|
||||
ws.send("ping-listener\n")
|
||||
|
||||
const start = Date.now()
|
||||
while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
}
|
||||
ws.close(1000, "done")
|
||||
|
||||
expect(messages.some((m) => m.includes("ping-listener"))).toBe(true)
|
||||
// Verify close event fires (handler.onClose path runs and the
|
||||
// Bun.serve websocket lifecycle reaches close).
|
||||
await closed
|
||||
expect(ws.readyState).toBe(WebSocket.CLOSED)
|
||||
} finally {
|
||||
await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, {
|
||||
method: "DELETE",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
} finally {
|
||||
await listener.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
|
||||
|
||||
const passthroughAuthorization = Layer.succeed(
|
||||
Authorization,
|
||||
Authorization.of({
|
||||
basic: (effect) => effect,
|
||||
authToken: (effect) => effect,
|
||||
}),
|
||||
Authorization.of((effect) => effect),
|
||||
)
|
||||
|
||||
const passthroughInstanceContext = Layer.succeed(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { serveUIEffect } from "../../src/server/shared/ui"
|
||||
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
|
||||
import { Server } from "../../src/server/server"
|
||||
|
||||
void Log.init({ print: false })
|
||||
@@ -184,6 +184,36 @@ describe("HttpApi UI fallback", () => {
|
||||
expect(await response.text()).toBe("console.log('ok')")
|
||||
})
|
||||
|
||||
test("serves embedded UI assets when Bun can read them but access reports missing", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
let readPath: string | undefined
|
||||
|
||||
const response = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
return yield* serveEmbeddedUIEffect(
|
||||
"/assets/app.js",
|
||||
{
|
||||
...fs,
|
||||
existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
|
||||
readFile: (path) => {
|
||||
readPath = path
|
||||
return path === "/$bunfs/root/assets/app.js"
|
||||
? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
|
||||
: Effect.die(`unexpected embedded UI path: ${path}`)
|
||||
},
|
||||
},
|
||||
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
|
||||
)
|
||||
}).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(readPath).toBe("/$bunfs/root/assets/app.js")
|
||||
expect(response.headers.get("content-type")).toContain("text/javascript")
|
||||
expect(await response.text()).toBe("console.log('embedded')")
|
||||
})
|
||||
|
||||
test("keeps matched API routes ahead of the UI fallback", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
|
||||
|
||||
@@ -8512,6 +8512,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextStepEnded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextStepFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextTextStarted"
|
||||
},
|
||||
@@ -8549,7 +8552,7 @@
|
||||
"$ref": "#/components/schemas/EventSessionNextToolSuccess"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextToolError"
|
||||
"$ref": "#/components/schemas/EventSessionNextToolFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextRetried"
|
||||
@@ -10708,6 +10711,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextStepEnded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextStepFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextTextStarted"
|
||||
},
|
||||
@@ -10745,7 +10751,7 @@
|
||||
"$ref": "#/components/schemas/EventSessionNextToolSuccess"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextToolError"
|
||||
"$ref": "#/components/schemas/EventSessionNextToolFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSessionNextRetried"
|
||||
@@ -10810,6 +10816,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextStepEnded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextStepFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextTextStarted"
|
||||
},
|
||||
@@ -10847,7 +10856,7 @@
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextToolSuccess"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextToolError"
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextToolFailed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SyncEventSessionNextRetried"
|
||||
@@ -14161,6 +14170,57 @@
|
||||
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncEventSessionNextStepFailed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["sync"]
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["session.next.step.failed.1"]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"seq": {
|
||||
"type": "number"
|
||||
},
|
||||
"aggregateID": {
|
||||
"type": "string",
|
||||
"enum": ["sessionID"]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "number"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "error"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncEventSessionNextTextStarted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14729,7 +14789,7 @@
|
||||
"required": ["type", "name", "id", "seq", "aggregateID", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SyncEventSessionNextToolError": {
|
||||
"SyncEventSessionNextToolFailed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
@@ -14738,7 +14798,7 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["session.next.tool.error.1"]
|
||||
"enum": ["session.next.tool.failed.1"]
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
@@ -16399,6 +16459,46 @@
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventSessionNextStepFailed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.next.step.failed"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timestamp": {
|
||||
"type": "number"
|
||||
},
|
||||
"sessionID": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["timestamp", "sessionID", "error"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventSessionNextTextStarted": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16869,7 +16969,7 @@
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventSessionNextToolError": {
|
||||
"EventSessionNextToolFailed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -16877,7 +16977,7 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["session.next.tool.error"]
|
||||
"enum": ["session.next.tool.failed"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
@@ -17700,7 +17800,17 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent", "model", "content"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
notifyShadowReady,
|
||||
observeViewerScheme,
|
||||
} from "../pierre/file-runtime"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
|
||||
@@ -26,7 +25,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const ready = createReadyWatcher()
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
@@ -51,14 +49,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
@@ -92,27 +82,15 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff.options,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
fileDiffInstance = new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...(local.preloadedDiff.options ?? {}),
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
|
||||
applyViewerScheme(fileDiffRef)
|
||||
|
||||
@@ -163,8 +141,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
fileDiffInstance?.cleanUp()
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||
import {
|
||||
DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
type DiffLineAnnotation,
|
||||
type FileContents,
|
||||
type FileDiffMetadata,
|
||||
@@ -10,10 +9,6 @@ import {
|
||||
type FileOptions,
|
||||
type LineAnnotation,
|
||||
type SelectedLineRange,
|
||||
type VirtualFileMetrics,
|
||||
VirtualizedFile,
|
||||
VirtualizedFileDiff,
|
||||
Virtualizer,
|
||||
} from "@pierre/diffs"
|
||||
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
@@ -40,19 +35,10 @@ import {
|
||||
readShadowLineSelection,
|
||||
} from "../pierre/file-selection"
|
||||
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
import { FileMedia, type FileMediaOptions } from "./file-media"
|
||||
import { FileSearchBar } from "./file-search"
|
||||
|
||||
const VIRTUALIZE_BYTES = 500_000
|
||||
|
||||
const codeMetrics = {
|
||||
...DEFAULT_VIRTUAL_FILE_METRICS,
|
||||
lineHeight: 24,
|
||||
fileGap: 0,
|
||||
} satisfies Partial<VirtualFileMetrics>
|
||||
|
||||
type SharedProps<T> = {
|
||||
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
@@ -386,11 +372,6 @@ type AnnotationTarget<A> = {
|
||||
rerender: () => void
|
||||
}
|
||||
|
||||
type VirtualStrategy = {
|
||||
get: () => Virtualizer | undefined
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
||||
return useFileViewer({
|
||||
enableLineSelection: config.enableLineSelection,
|
||||
@@ -532,64 +513,6 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
||||
let virtualizer: Virtualizer | undefined
|
||||
let root: Document | HTMLElement | undefined
|
||||
|
||||
const release = () => {
|
||||
virtualizer?.cleanUp()
|
||||
virtualizer = undefined
|
||||
root = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!enabled()) {
|
||||
release()
|
||||
return
|
||||
}
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const wrapper = host()
|
||||
if (!wrapper) return
|
||||
|
||||
const next = scrollParent(wrapper) ?? document
|
||||
if (virtualizer && root === next) return virtualizer
|
||||
|
||||
release()
|
||||
virtualizer = new Virtualizer()
|
||||
root = next
|
||||
virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
|
||||
return virtualizer
|
||||
},
|
||||
cleanup: release,
|
||||
}
|
||||
}
|
||||
|
||||
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
|
||||
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const release = () => {
|
||||
shared?.release()
|
||||
shared = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (shared) return shared.virtualizer
|
||||
|
||||
const container = host()
|
||||
if (!container) return
|
||||
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
shared = result
|
||||
return result.virtualizer
|
||||
},
|
||||
cleanup: release,
|
||||
}
|
||||
}
|
||||
|
||||
function parseLine(node: HTMLElement) {
|
||||
if (!node.dataset.line) return
|
||||
const value = parseInt(node.dataset.line, 10)
|
||||
@@ -688,7 +611,7 @@ function ViewerShell(props: {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TextViewer<T>(props: TextFileProps<T>) {
|
||||
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
|
||||
let instance: PierreFile<T> | undefined
|
||||
let viewer!: Viewer
|
||||
|
||||
const [local, others] = splitProps(props, textKeys)
|
||||
@@ -708,36 +631,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
return Math.max(1, total)
|
||||
}
|
||||
|
||||
const bytes = createMemo(() => {
|
||||
const value = local.file.contents as unknown
|
||||
if (typeof value === "string") return value.length
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce(
|
||||
// oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
|
||||
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (value == null) return 0
|
||||
// oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
|
||||
return String(value).length
|
||||
})
|
||||
|
||||
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
|
||||
|
||||
const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
|
||||
|
||||
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
|
||||
|
||||
const applySelection = (range: SelectedLineRange | null) => {
|
||||
const current = instance
|
||||
if (!current) return false
|
||||
|
||||
if (virtual()) {
|
||||
current.setSelectedLines(range)
|
||||
return true
|
||||
}
|
||||
|
||||
const root = viewer.getRoot()
|
||||
if (!root) return false
|
||||
|
||||
@@ -836,10 +735,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
const notify = () => {
|
||||
notifyRendered({
|
||||
viewer,
|
||||
isReady: (root) => {
|
||||
if (virtual()) return root.querySelector("[data-line]") != null
|
||||
return root.querySelectorAll("[data-line]").length >= lineCount()
|
||||
},
|
||||
isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(),
|
||||
onReady: () => {
|
||||
applySelection(viewer.lastSelection)
|
||||
viewer.find.refresh({ reset: true })
|
||||
@@ -858,17 +754,11 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = getWorkerPool("unified")
|
||||
const isVirtual = virtual()
|
||||
|
||||
const virtualizer = virtuals.get()
|
||||
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
create: () =>
|
||||
isVirtual && virtualizer
|
||||
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
|
||||
: new PierreFile<T>(opts, workerPool),
|
||||
create: () => new PierreFile<T>(opts, workerPool),
|
||||
assign: (value) => {
|
||||
instance = value
|
||||
},
|
||||
@@ -895,7 +785,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
instance?.cleanUp()
|
||||
instance = undefined
|
||||
virtuals.cleanup()
|
||||
})
|
||||
|
||||
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
|
||||
@@ -991,8 +880,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
adapter,
|
||||
)
|
||||
|
||||
const virtuals = createSharedVirtualStrategy(() => viewer.container)
|
||||
|
||||
const large = createMemo(() => {
|
||||
if (local.fileDiff) {
|
||||
const before = local.fileDiff.deletionLines.join("")
|
||||
@@ -1055,7 +942,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
|
||||
const virtualizer = virtuals.get()
|
||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
const done = preserve(viewer)
|
||||
@@ -1070,10 +956,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
renderViewer({
|
||||
viewer,
|
||||
current: instance,
|
||||
create: () =>
|
||||
virtualizer
|
||||
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
||||
: new FileDiff<T>(opts, workerPool),
|
||||
create: () => new FileDiff<T>(opts, workerPool),
|
||||
assign: (value) => {
|
||||
instance = value
|
||||
},
|
||||
@@ -1111,7 +994,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
onCleanup(() => {
|
||||
instance?.cleanUp()
|
||||
instance = undefined
|
||||
virtuals.cleanup()
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
})
|
||||
|
||||
@@ -26,7 +26,6 @@ import type { LineCommentEditorProps } from "./line-comment"
|
||||
import { normalize, text, type ViewDiff } from "./session-diff"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
const REVIEW_MOUNT_MARGIN = 300
|
||||
|
||||
export type SessionReviewDiffStyle = "unified" | "split"
|
||||
|
||||
@@ -159,14 +158,11 @@ type SessionReviewSelection = {
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
let frame: number | undefined
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const anchors = new Map<string, HTMLElement>()
|
||||
const nodes = new Map<string, HTMLDivElement>()
|
||||
const [store, setStore] = createStore({
|
||||
open: [] as string[],
|
||||
visible: {} as Record<string, boolean>,
|
||||
force: {} as Record<string, boolean>,
|
||||
selection: null as SessionReviewSelection | null,
|
||||
commenting: null as SessionReviewSelection | null,
|
||||
@@ -196,44 +192,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
const syncVisible = () => {
|
||||
frame = undefined
|
||||
if (!scroll) return
|
||||
|
||||
const root = scroll.getBoundingClientRect()
|
||||
const top = root.top - REVIEW_MOUNT_MARGIN
|
||||
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
||||
const openSet = new Set(open())
|
||||
const next: Record<string, boolean> = {}
|
||||
|
||||
for (const [file, el] of nodes) {
|
||||
if (!openSet.has(file)) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom < top || rect.top > bottom) continue
|
||||
next[file] = true
|
||||
}
|
||||
|
||||
const prev = untrack(() => store.visible)
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(next)
|
||||
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
||||
setStore("visible", next)
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(syncVisible)
|
||||
}
|
||||
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
const next = props.onScroll
|
||||
if (!next) return
|
||||
if (Array.isArray(next)) {
|
||||
@@ -244,21 +203,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.open
|
||||
files()
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
props.onOpenChange?.(next)
|
||||
if (props.open === undefined) setStore("open", next)
|
||||
queue()
|
||||
}
|
||||
|
||||
const handleExpandOrCollapseAll = () => {
|
||||
@@ -372,7 +319,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
props.scrollRef?.(el)
|
||||
queue()
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
classList={{
|
||||
@@ -391,7 +337,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const diffCanRender = () => diff.additions !== 0 || diff.deletions !== 0
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
const force = () => !!store.force[file]
|
||||
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
@@ -482,8 +427,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
nodes.delete(file)
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
@@ -569,19 +512,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
data-slot="session-review-diff-wrapper"
|
||||
ref={(el) => {
|
||||
anchors.set(file, el)
|
||||
nodes.set(file, el)
|
||||
queue()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<Switch>
|
||||
<Match when={!mounted() && !tooLarge()}>
|
||||
<div
|
||||
data-slot="session-review-diff-placeholder"
|
||||
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
||||
style={{ height: "160px" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={tooLarge()}>
|
||||
<div data-slot="session-review-large-diff">
|
||||
<div data-slot="session-review-large-diff-title">
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
|
||||
|
||||
type Target = {
|
||||
key: Document | HTMLElement
|
||||
root: Document | HTMLElement
|
||||
content: HTMLElement | undefined
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
virtualizer: Virtualizer
|
||||
refs: number
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Document | HTMLElement, Entry>()
|
||||
|
||||
export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
lineHeight: 24,
|
||||
hunkSeparatorHeight: 24,
|
||||
fileGap: 0,
|
||||
}
|
||||
|
||||
function scrollable(value: string) {
|
||||
return value === "auto" || value === "scroll" || value === "overlay"
|
||||
}
|
||||
|
||||
function scrollRoot(container: HTMLElement) {
|
||||
let node = container.parentElement
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
if (scrollable(style.overflowY)) return node
|
||||
node = node.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
function target(container: HTMLElement): Target | undefined {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const review = container.closest("[data-component='session-review']")
|
||||
if (review instanceof HTMLElement) {
|
||||
const root = scrollRoot(container) ?? review
|
||||
const content = review.querySelector("[data-slot='session-review-container']")
|
||||
return {
|
||||
key: review,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const root = scrollRoot(container)
|
||||
if (root) {
|
||||
const content = root.querySelector("[role='log']")
|
||||
return {
|
||||
key: root,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: document,
|
||||
root: document,
|
||||
content: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireVirtualizer(container: HTMLElement) {
|
||||
const resolved = target(container)
|
||||
if (!resolved) return
|
||||
|
||||
let entry = cache.get(resolved.key)
|
||||
if (!entry) {
|
||||
const virtualizer = new Virtualizer()
|
||||
virtualizer.setup(resolved.root, resolved.content)
|
||||
entry = {
|
||||
virtualizer,
|
||||
refs: 0,
|
||||
}
|
||||
cache.set(resolved.key, entry)
|
||||
}
|
||||
|
||||
entry.refs += 1
|
||||
let done = false
|
||||
|
||||
return {
|
||||
virtualizer: entry.virtualizer,
|
||||
release() {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
const current = cache.get(resolved.key)
|
||||
if (!current) return
|
||||
|
||||
current.refs -= 1
|
||||
if (current.refs > 0) return
|
||||
|
||||
current.virtualizer.cleanUp()
|
||||
cache.delete(resolved.key)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user