mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 12:10:01 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c96f28aecf | |||
| 58366eba5f | |||
| 49ddad1dd8 | |||
| bcd29d6d2e |
@@ -0,0 +1,42 @@
|
||||
import { $ } from "bun"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export type AppAsset = {
|
||||
readonly key: string
|
||||
readonly source: string
|
||||
readonly content: string
|
||||
readonly encoding: "utf8" | "base64"
|
||||
}
|
||||
|
||||
export async function buildAppAssets(channel: string) {
|
||||
const root = path.resolve(import.meta.dirname, "../../app")
|
||||
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||
return Promise.all(
|
||||
(await files(path.join(root, "dist")))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.map(async (key): Promise<AppAsset> => {
|
||||
const source = path.join(root, "dist", key)
|
||||
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
||||
const encoding = isText(key) ? "utf8" : "base64"
|
||||
return { key, source, encoding, content: body.toString(encoding) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function isText(key: string) {
|
||||
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.toSorted()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { modelsData } from "./generate"
|
||||
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
import { buildAppAssets } from "./app-assets"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -55,13 +56,23 @@ const builder =
|
||||
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
|
||||
? await resolveHostNode()
|
||||
: undefined
|
||||
const appAssets = await buildAppAssets(Script.channel)
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`building cli-node-${targetName(target)}`)
|
||||
const assets = await collectNodeAssets(target)
|
||||
await rm("dist-node", { recursive: true, force: true })
|
||||
const assetHash = await hashNodeAssets(assets)
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
const input = {
|
||||
version: Script.version,
|
||||
channel: Script.channel,
|
||||
models: modelsData,
|
||||
assetHash,
|
||||
target,
|
||||
appAssets: Object.fromEntries(
|
||||
appAssets.map((asset) => [asset.key, { content: asset.content, encoding: asset.encoding }]),
|
||||
),
|
||||
}
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
import { buildAppAssets } from "./app-assets"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -54,6 +55,24 @@ const targets = singleFlag
|
||||
: allTargets
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
const appAssets = await buildAppAssets(Script.channel)
|
||||
const appAssetsPlugin: BunPlugin = {
|
||||
name: "opencode-app-assets",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^virtual:opencode-app-assets$/ }, () => ({
|
||||
path: "opencode-app-assets",
|
||||
namespace: "opencode",
|
||||
}))
|
||||
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
|
||||
loader: "js",
|
||||
contents: `export default ${JSON.stringify(
|
||||
Object.fromEntries(
|
||||
appAssets.map((asset) => [asset.key, { content: asset.content, encoding: asset.encoding }]),
|
||||
),
|
||||
)}`,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
@@ -80,7 +99,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin, parcelWatcherPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_LOCAL } from "./version"
|
||||
|
||||
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
|
||||
type EncodedAssetMap = Readonly<
|
||||
Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>
|
||||
>
|
||||
|
||||
export const load = Effect.fn("cli.app-assets.load")(function* () {
|
||||
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
|
||||
if (Option.isSome(embedded) && Object.keys(embedded.value.default).length > 0)
|
||||
return decode(embedded.value.default)
|
||||
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
|
||||
return decode(yield* sourceAssets())
|
||||
})
|
||||
|
||||
const sourceAssets = Effect.fnUntraced(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = path.resolve(import.meta.dirname, "../../app/dist")
|
||||
const files = yield* fs.readDirectory(root, { recursive: true })
|
||||
return Object.fromEntries(
|
||||
(
|
||||
yield* Effect.forEach(
|
||||
files.filter((file) => !file.endsWith(".map")),
|
||||
Effect.fnUntraced(function* (file) {
|
||||
const target = path.join(root, file)
|
||||
if ((yield* fs.stat(target)).type === "Directory") return
|
||||
const body = Buffer.from(yield* fs.readFile(target))
|
||||
const encoding = isText(file) ? "utf8" : "base64"
|
||||
return [file, { encoding, content: body.toString(encoding) }] as const
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
).filter((asset) => asset !== undefined),
|
||||
)
|
||||
})
|
||||
|
||||
function decode(assets: EncodedAssetMap): AssetMap {
|
||||
return Object.fromEntries(
|
||||
Object.entries(assets).map(([key, asset]) => [
|
||||
key,
|
||||
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function isText(file: string) {
|
||||
return file === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(file)
|
||||
}
|
||||
@@ -266,8 +266,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
],
|
||||
}),
|
||||
Spec.make("pair", { description: "Show server pairing information" }),
|
||||
Spec.make("web", {
|
||||
description: "Start the server and open the web interface",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
description: "Start the v2 API and web server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
|
||||
@@ -4,12 +4,13 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { Context, Effect, FileSystem, Option, Ref, Scope } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
|
||||
import { WebUi } from "../../services/web-ui"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -37,11 +38,13 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
preflight.loading()
|
||||
const endpoint = yield* Ref.make(server.endpoint)
|
||||
const web = Ref.get(endpoint).pipe(Effect.map(WebUi.url))
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
const context = yield* Effect.context<FileSystem.FileSystem>()
|
||||
const context = yield* Effect.context<FileSystem.FileSystem | Scope.Scope>()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
@@ -55,11 +58,16 @@ export default Runtime.handler(Commands, (input) =>
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
? {
|
||||
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
|
||||
reconnect: (signal) =>
|
||||
runServicePromise(
|
||||
service.reconnect().pipe(Effect.tap((next) => Ref.set(endpoint, next))),
|
||||
{ signal },
|
||||
),
|
||||
restart: () => runServicePromise(service.restart()),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
web: () => runPromise(web),
|
||||
args: {
|
||||
continue: input.continue,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerProcess } from "../../server-process"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.web,
|
||||
Effect.fnUntraced(function* (input) {
|
||||
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
|
||||
return yield* ServerProcess.run({
|
||||
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
||||
open: true,
|
||||
hostname: Option.getOrUndefined(input.hostname),
|
||||
port: Option.getOrUndefined(input.port),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -52,6 +52,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
unset: () => import("./commands/handlers/service/unset"),
|
||||
},
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
web: () => import("./commands/handlers/web"),
|
||||
})
|
||||
|
||||
Effect.logInfo("cli starting", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Service, type DiscoverOptions, type Endpoint, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
@@ -13,11 +13,14 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
import open from "open"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
|
||||
export type Options = {
|
||||
readonly mode: Mode
|
||||
readonly open?: boolean
|
||||
readonly hostname?: string
|
||||
readonly port?: number
|
||||
}
|
||||
@@ -42,16 +45,19 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
if (
|
||||
serviceOptions !== undefined &&
|
||||
port !== undefined &&
|
||||
(yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
|
||||
)
|
||||
const incumbent =
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) {
|
||||
if (options.open) yield* openWeb(incumbent.endpoint)
|
||||
return
|
||||
}
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
@@ -67,6 +73,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
: randomBytes(32).toString("base64url")
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const transform = yield* WebUi.handler()
|
||||
const server = yield* start(
|
||||
{
|
||||
app: {
|
||||
@@ -121,6 +128,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
@@ -142,7 +150,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (server === undefined) return
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
if (options.open)
|
||||
yield* openWeb({ url, auth: { type: "basic", username: "opencode", password } })
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
@@ -213,6 +223,12 @@ function serviceURL(hostname: string, port: number) {
|
||||
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||
}
|
||||
|
||||
function openWeb(endpoint: Endpoint) {
|
||||
const target = new URL(WebUi.url(endpoint))
|
||||
if (target.hostname === "0.0.0.0" || target.hostname === "::") target.hostname = "localhost"
|
||||
return Effect.promise(() => open(target.toString()).catch(() => undefined))
|
||||
}
|
||||
|
||||
function truthy(value?: string) {
|
||||
return value === "1" || value?.toLowerCase() === "true"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createHash } from "node:crypto"
|
||||
import { load, type AssetMap } from "../app-assets"
|
||||
|
||||
export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: {
|
||||
readonly assets?: AssetMap
|
||||
}) {
|
||||
const assets = options?.assets ?? (yield* load())
|
||||
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
api.pipe(
|
||||
Effect.catchIf(
|
||||
isRouteNotFound,
|
||||
() =>
|
||||
HttpServerRequest.HttpServerRequest.pipe(
|
||||
Effect.flatMap((request) => serveUI(request, new URL(request.url, "http://localhost"), assets)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export function url(endpoint: Endpoint) {
|
||||
const target = new URL(endpoint.url)
|
||||
if (endpoint.auth) {
|
||||
target.username = endpoint.auth.username
|
||||
target.password = endpoint.auth.password
|
||||
}
|
||||
return target.toString()
|
||||
}
|
||||
|
||||
function serveUI(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
url: URL,
|
||||
assets: AssetMap,
|
||||
) {
|
||||
const key = url.pathname.replace(/^\//, "")
|
||||
const name = assets[key] !== undefined ? key : "index.html"
|
||||
const file = assets[name]
|
||||
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
|
||||
const html = name === "index.html"
|
||||
const headers = {
|
||||
"content-type": FSUtil.mimeType(name),
|
||||
"cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
|
||||
"content-security-policy": html ? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString()) : csp(),
|
||||
"x-content-type-options": "nosniff",
|
||||
}
|
||||
return Effect.succeed(
|
||||
request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }),
|
||||
)
|
||||
}
|
||||
|
||||
function isRouteNotFound(error: unknown) {
|
||||
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
|
||||
}
|
||||
|
||||
function csp(hash = "") {
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
|
||||
}
|
||||
|
||||
function cspForHtml(body: string) {
|
||||
const match = body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
|
||||
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
|
||||
}
|
||||
|
||||
export * as WebUi from "./web-ui"
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
declare module "virtual:opencode-app-assets" {
|
||||
const assets: Readonly<
|
||||
Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>
|
||||
>
|
||||
export default assets
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { WebUi } from "../src/services/web-ui"
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
|
||||
afterAll(() => rm(root, { recursive: true, force: true }))
|
||||
|
||||
describe("web UI", () => {
|
||||
test("falls back from API routes to assets and the SPA index", async () => {
|
||||
const index = path.join(root, "index.html")
|
||||
const asset = path.join(root, "app.js")
|
||||
await writeFile(index, "<html><body>embedded</body></html>")
|
||||
await writeFile(asset, "console.log('embedded')")
|
||||
const assets = {
|
||||
"index.html": await Bun.file(index).text(),
|
||||
"app.js": await Bun.file(asset).text(),
|
||||
"font.woff2": new Uint8Array([0, 1, 2, 255]),
|
||||
}
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const transform = yield* WebUi.handler({ assets })
|
||||
const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
|
||||
yield* http.serve(
|
||||
transform(
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const pathname = new URL(request.url, "http://localhost").pathname
|
||||
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
|
||||
if (pathname === "/api/missing")
|
||||
return HttpServerResponse.jsonUnsafe({ code: "missing" }, { status: 404 })
|
||||
return yield* Effect.fail(
|
||||
new HttpServerError.HttpServerError({
|
||||
reason: new HttpServerError.RouteNotFound({ request }),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const origin = HttpServer.formatAddress(http.address)
|
||||
|
||||
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
|
||||
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
|
||||
|
||||
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
|
||||
expect(missing.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => missing.json())).toEqual({ code: "missing" })
|
||||
|
||||
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
|
||||
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
|
||||
|
||||
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
|
||||
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(new Uint8Array([0, 1, 2, 255]))
|
||||
|
||||
const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
|
||||
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
|
||||
}),
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
})
|
||||
|
||||
test("adds server credentials to browser URL userinfo", () => {
|
||||
const target = new URL(
|
||||
WebUi.url({
|
||||
url: "http://localhost:4096",
|
||||
auth: { type: "basic", username: "opencode", password: "secret" },
|
||||
}),
|
||||
)
|
||||
expect(target.username).toBe("opencode")
|
||||
expect(target.password).toBe("secret")
|
||||
expect(target.searchParams.has("auth_token")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,21 @@ function rawTextPlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
type AppAsset = { readonly content: string; readonly encoding: "utf8" | "base64" }
|
||||
|
||||
function appAssetsPlugin(assets: Readonly<Record<string, AppAsset>>): Plugin {
|
||||
return {
|
||||
name: "opencode:app-assets",
|
||||
resolveId(id) {
|
||||
if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
|
||||
},
|
||||
load(id) {
|
||||
if (id !== "\0virtual:opencode-app-assets") return
|
||||
return `export default ${JSON.stringify(assets)}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeRequirePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:runtime-require",
|
||||
@@ -212,12 +227,14 @@ export type NodeBuildInput = {
|
||||
readonly models: string
|
||||
readonly assetHash: string
|
||||
readonly target: NodeTarget
|
||||
readonly appAssets: Readonly<Record<string, AppAsset>>
|
||||
}
|
||||
|
||||
export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
return defineConfig({
|
||||
root: dir,
|
||||
plugins: [
|
||||
appAssetsPlugin(input.appAssets),
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
@@ -259,4 +276,5 @@ export default mainConfig({
|
||||
models: "undefined",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
appAssets: {},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,13 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import {
|
||||
HttpMiddleware,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
@@ -31,6 +37,8 @@ type App = Effect.Effect<
|
||||
HttpServerRequest.HttpServerRequest | Scope.Scope
|
||||
>
|
||||
|
||||
export type Transform = (app: App) => App
|
||||
|
||||
const errorResponseLogger = HttpMiddleware.make((app) =>
|
||||
HttpMiddleware.logger(
|
||||
Effect.tap(app, (response) =>
|
||||
@@ -42,6 +50,7 @@ const errorResponseLogger = HttpMiddleware.make((app) =>
|
||||
export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
options: ServerOptions,
|
||||
lifecycle?: Lifecycle<E, R>,
|
||||
transform?: Transform,
|
||||
) {
|
||||
const password = options.password
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
@@ -101,7 +110,8 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
Effect.provideService(Scope.Scope, applicationScope),
|
||||
)
|
||||
}
|
||||
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
|
||||
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
|
||||
}).pipe(
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("allows browser preflight requests without credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const server = yield* ServerProcess.start<never, never>(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
},
|
||||
undefined,
|
||||
(api) =>
|
||||
api.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
|
||||
() => Effect.succeed(HttpServerResponse.text("fallback")),
|
||||
),
|
||||
),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
method: "OPTIONS",
|
||||
@@ -40,5 +50,13 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
)
|
||||
expect(missing.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => missing.text())).toBe("fallback")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -179,6 +179,7 @@ export type TuiInput = {
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
packages: PackageResolver
|
||||
web?: () => Promise<string>
|
||||
terminalHandoff?: () => Promise<
|
||||
| {
|
||||
readonly renderer: CliRenderer
|
||||
@@ -375,6 +376,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
web={input.web}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
@@ -434,7 +436,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials }) {
|
||||
function App(props: { pair?: DialogPairCredentials; web?: () => Promise<string> }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
@@ -952,6 +954,33 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(props.web
|
||||
? [
|
||||
{
|
||||
name: "web.open",
|
||||
title: "Open web interface",
|
||||
slash: { name: "web" },
|
||||
run: async () => {
|
||||
const web = props.web
|
||||
if (!web) return
|
||||
const url = await web().catch((error) => {
|
||||
toast.error(error)
|
||||
return undefined
|
||||
})
|
||||
if (!url) return
|
||||
await open(url).catch(() =>
|
||||
toast.show({
|
||||
title: "Could not open browser",
|
||||
message: `Open ${url} manually.`,
|
||||
variant: "warning",
|
||||
}),
|
||||
)
|
||||
dialog.clear()
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "app.exit",
|
||||
title: "Exit the app",
|
||||
|
||||
Reference in New Issue
Block a user