Explorar o código

feat(cli): embed web ui (#41525)

Co-authored-by: Dax Raad <d@ironbay.co>
Brendan Allan hai 5 días
pai
achega
20df2fdba6

+ 41 - 0
packages/cli/script/app-assets.ts

@@ -0,0 +1,41 @@
+import { $ } from "bun"
+import { readdir } from "node:fs/promises"
+import path from "node:path"
+import { brotliCompressSync, constants } from "node:zlib"
+
+export async function buildAppArchive(channel: string) {
+  const root = path.resolve(import.meta.dirname, "../../app")
+  await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
+  const assets = Object.fromEntries(
+    await Promise.all(
+      (await files(path.join(root, "dist")))
+        .filter((key) => !key.endsWith(".map"))
+        .map(async (key) => {
+          const source = path.join(root, "dist", key)
+          const body = Buffer.from(await Bun.file(source).arrayBuffer())
+          const encoding = isText(key) ? "utf8" : "base64"
+          return [key, { encoding, content: body.toString(encoding) }] as const
+        }),
+    ),
+  )
+  return brotliCompressSync(JSON.stringify(assets), {
+    params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
+  }).toString("base64")
+}
+
+function isText(key: string) {
+  return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
+}
+
+async function files(root: string, current = root): Promise<string[]> {
+  return (
+    await Promise.all(
+      (await readdir(current, { withFileTypes: true })).map((entry) => {
+        const target = path.join(current, entry.name)
+        return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
+      }),
+    )
+  )
+    .flat()
+    .toSorted()
+}

+ 10 - 1
packages/cli/script/build-node.ts

@@ -12,6 +12,7 @@ import { modelsData } from "./generate"
 import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
 import { mainConfig } from "../vite.node.config"
 import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
+import { buildAppArchive } from "./app-assets"
 
 const NODE_VERSION = "26.4.0"
 const dir = path.resolve(import.meta.dirname, "..")
@@ -55,13 +56,21 @@ const builder =
   !bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
     ? await resolveHostNode()
     : undefined
+const appArchive = await buildAppArchive(Script.channel)
 
 for (const target of targets) {
   console.log(`building cli-node-${targetName(target)}`)
   const assets = await collectNodeAssets(target)
   await rm("dist-node", { recursive: true, force: true })
   const assetHash = await hashNodeAssets(assets)
-  const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
+  const input = {
+    version: Script.version,
+    channel: Script.channel,
+    models: modelsData,
+    assetHash,
+    target,
+    appArchive,
+  }
   await copyNodeAssets(assets)
   await build(mainConfig(input))
 

+ 16 - 1
packages/cli/script/build.ts

@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
 import type { BunPlugin } from "bun"
 import pkg from "../package.json"
 import { modelsData } from "./generate"
+import { buildAppArchive } from "./app-assets"
 
 const dir = path.resolve(import.meta.dirname, "..")
 const binary = "opencode2"
@@ -54,6 +55,20 @@ const targets = singleFlag
   : allTargets
 
 if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
+const appArchive = await buildAppArchive(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(appArchive)}`,
+    }))
+  },
+}
 
 for (const item of targets) {
   const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
@@ -80,7 +95,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,

+ 55 - 0
packages/cli/src/app-assets.ts

@@ -0,0 +1,55 @@
+import { Effect, FileSystem, Option } from "effect"
+import path from "node:path"
+import { brotliDecompressSync } from "node:zlib"
+import { OPENCODE_LOCAL } from "./version"
+
+export type AssetMap = Readonly<Record<string, string | Uint8Array>>
+type EncodedAssetMap = Readonly<
+  Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>
+>
+
+export const load = Effect.fn("cli.app-assets.load")(function* () {
+  const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
+  if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
+  if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
+  return decode(yield* sourceAssets())
+})
+
+function decodeArchive(archive: string) {
+  const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
+  return decode(JSON.parse(body) as EncodedAssetMap)
+}
+
+const sourceAssets = Effect.fnUntraced(function* () {
+  const fs = yield* FileSystem.FileSystem
+  const root = path.resolve(import.meta.dirname, "../../app/dist")
+  const files = yield* fs.readDirectory(root, { recursive: true })
+  return Object.fromEntries(
+    (
+      yield* Effect.forEach(
+        files.filter((file) => !file.endsWith(".map")),
+        Effect.fnUntraced(function* (file) {
+          const target = path.join(root, file)
+          if ((yield* fs.stat(target)).type === "Directory") return
+          const body = Buffer.from(yield* fs.readFile(target))
+          const encoding = isText(file) ? "utf8" : "base64"
+          return [file, { encoding, content: body.toString(encoding) }] as const
+        }),
+        { concurrency: "unbounded" },
+      )
+    ).filter((asset) => asset !== undefined),
+  )
+})
+
+function decode(assets: EncodedAssetMap): AssetMap {
+  return Object.fromEntries(
+    Object.entries(assets).map(([key, asset]) => [
+      key,
+      asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
+    ]),
+  )
+}
+
+function isText(file: string) {
+  return file === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(file)
+}

+ 1 - 1
packages/cli/src/commands/commands.ts

@@ -267,7 +267,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
     }),
     Spec.make("pair", { description: "Show server pairing information" }),
     Spec.make("serve", {
-      description: "Start the v2 API server",
+      description: "Start the v2 API and web server",
       params: {
         hostname: Flag.string("hostname").pipe(Flag.optional),
         port: Flag.integer("port").pipe(Flag.optional),

+ 10 - 7
packages/cli/src/server-process.ts

@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
 import { Env } from "./env"
 import { ServiceConfig } from "./services/service-config"
 import { Updater } from "./services/updater"
+import { WebUi } from "./services/web-ui"
 
 export type Mode = "default" | "service" | "stdio"
 
@@ -43,16 +44,16 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
   if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
   return yield* Effect.scoped(
     Effect.gen(function* () {
+      const foreground = options.mode === "default"
       const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
       const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
       const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
       const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
-      if (
-        serviceOptions !== undefined &&
-        port !== undefined &&
-        (yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })) !== undefined
-      )
-        return
+      const incumbent =
+        serviceOptions !== undefined && port !== undefined
+          ? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
+          : undefined
+      if (incumbent !== undefined) return
       const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
       const environmentPassword = yield* Env.password
       // Keep the lease credential out of the environment inherited by tools.
@@ -68,6 +69,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
             : randomBytes(32).toString("base64url")
       if (!password) return yield* Effect.fail(new Error("Missing server password"))
       const instanceID = randomUUID()
+      const transform = yield* WebUi.handler()
       const server = yield* start(
         {
           app: {
@@ -122,6 +124,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
                   return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
                 }),
             },
+        transform,
       ).pipe(
         Effect.catch((error) => {
           if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
@@ -143,7 +146,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
       if (server === undefined) return
       const url = HttpServer.formatAddress(server.address)
       console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
-      if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
+      if (foreground && !environmentPassword) console.log(`server password ${password}`)
       const updater = yield* Updater.Service
       yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
       return yield* options.mode === "service"

+ 66 - 0
packages/cli/src/services/web-ui.ts

@@ -0,0 +1,66 @@
+import { FSUtil } from "@opencode-ai/util/fs-util"
+import { Effect, FileSystem } from "effect"
+import { HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
+import { createHash } from "node:crypto"
+import { load, type AssetMap } from "../app-assets"
+
+export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: {
+  readonly assets?: AssetMap
+}) {
+  const fileSystem = yield* FileSystem.FileSystem
+  const assets = options?.assets
+    ? Effect.succeed(options.assets)
+    : yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
+  return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
+    api.pipe(
+      Effect.catchIf(
+        isRouteNotFound,
+        () =>
+          HttpServerRequest.HttpServerRequest.pipe(
+            Effect.flatMap((request) => {
+              const url = new URL(request.url, "http://localhost")
+              if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
+                return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
+              return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
+            }),
+          ),
+      ),
+    )
+})
+
+function serveUI(
+  request: HttpServerRequest.HttpServerRequest,
+  url: URL,
+  assets: AssetMap,
+) {
+  const key = url.pathname.replace(/^\//, "")
+  const name = assets[key] !== undefined ? key : "index.html"
+  const file = assets[name]
+  if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
+  if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
+  const html = name === "index.html"
+  const headers = {
+    "content-type": FSUtil.mimeType(name),
+    "cache-control": html ? "no-cache" : "public, max-age=31536000, immutable",
+    "content-security-policy": html ? cspForHtml(typeof file === "string" ? file : Buffer.from(file).toString()) : csp(),
+    "x-content-type-options": "nosniff",
+  }
+  return Effect.succeed(
+    request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }),
+  )
+}
+
+function isRouteNotFound(error: unknown) {
+  return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
+}
+
+function csp(hash = "") {
+  return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
+}
+
+function cspForHtml(body: string) {
+  const match = body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(["'])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
+  return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
+}
+
+export * as WebUi from "./web-ui"

+ 4 - 0
packages/cli/src/virtual.d.ts

@@ -0,0 +1,4 @@
+declare module "virtual:opencode-app-assets" {
+  const archive: string
+  export default archive
+}

+ 68 - 0
packages/cli/test/web-ui.test.ts

@@ -0,0 +1,68 @@
+import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
+import { afterAll, describe, expect, test } from "bun:test"
+import { Effect } from "effect"
+import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
+import { createServer } from "node:http"
+import { mkdtemp, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { WebUi } from "../src/services/web-ui"
+
+const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
+afterAll(() => rm(root, { recursive: true, force: true }))
+
+describe("web UI", () => {
+  test("falls back from API routes to assets and the SPA index", async () => {
+    const index = path.join(root, "index.html")
+    const asset = path.join(root, "app.js")
+    await writeFile(index, "<html><body>embedded</body></html>")
+    await writeFile(asset, "console.log('embedded')")
+    const assets = {
+      "index.html": await Bun.file(index).text(),
+      "app.js": await Bun.file(asset).text(),
+      "font.woff2": new Uint8Array([0, 1, 2, 255]),
+    }
+
+    await Effect.runPromise(
+      Effect.scoped(
+        Effect.gen(function* () {
+          const transform = yield* WebUi.handler({ assets })
+          const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
+          yield* http.serve(
+            transform(
+              Effect.gen(function* () {
+                const request = yield* HttpServerRequest.HttpServerRequest
+                const pathname = new URL(request.url, "http://localhost").pathname
+                if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
+                return yield* Effect.fail(
+                  new HttpServerError.HttpServerError({
+                    reason: new HttpServerError.RouteNotFound({ request }),
+                  }),
+                )
+              }),
+            ),
+          )
+          const origin = HttpServer.formatAddress(http.address)
+
+          const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
+          expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
+
+          const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
+          expect(missing.status).toBe(404)
+          expect(yield* Effect.promise(() => missing.text())).toBe("")
+
+          const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
+          expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
+
+          const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
+          expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(new Uint8Array([0, 1, 2, 255]))
+
+          const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
+          expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
+          expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
+          expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
+        }),
+      ).pipe(Effect.provide(NodeFileSystem.layer)),
+    )
+  })
+})

+ 16 - 0
packages/cli/vite.node.config.ts

@@ -17,6 +17,19 @@ function rawTextPlugin(): Plugin {
   }
 }
 
+function appAssetsPlugin(archive: string): Plugin {
+  return {
+    name: "opencode:app-assets",
+    resolveId(id) {
+      if (id === "virtual:opencode-app-assets") return "\0virtual:opencode-app-assets"
+    },
+    load(id) {
+      if (id !== "\0virtual:opencode-app-assets") return
+      return `export default ${JSON.stringify(archive)}`
+    },
+  }
+}
+
 function runtimeRequirePlugin(): Plugin {
   return {
     name: "opencode:runtime-require",
@@ -212,12 +225,14 @@ export type NodeBuildInput = {
   readonly models: string
   readonly assetHash: string
   readonly target: NodeTarget
+  readonly appArchive: string
 }
 
 export function mainConfig(input: NodeBuildInput): UserConfig {
   return defineConfig({
     root: dir,
     plugins: [
+      appAssetsPlugin(input.appArchive),
       rawTextPlugin(),
       runtimeRequirePlugin(),
       fffNodePlugin(),
@@ -259,4 +274,5 @@ export default mainConfig({
   models: "undefined",
   assetHash: "local",
   target: nodeTarget(process.platform, process.arch),
+  appArchive: "",
 })

+ 3 - 3
packages/client/src/promise/generated/client.ts

@@ -1740,7 +1740,7 @@ export function make(options: ClientOptions) {
         request<ProjectCopyCreateOutput>(
           {
             method: "POST",
-            path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
+            path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
             query: { location: input["location"] },
             body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
             successStatus: 200,
@@ -1753,7 +1753,7 @@ export function make(options: ClientOptions) {
         request<ProjectCopyRemoveOutput>(
           {
             method: "DELETE",
-            path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
+            path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
             query: { location: input["location"] },
             body: { directory: input["directory"], force: input["force"] },
             successStatus: 204,
@@ -1766,7 +1766,7 @@ export function make(options: ClientOptions) {
         request<ProjectCopyRefreshOutput>(
           {
             method: "POST",
-            path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
+            path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
             query: { location: input["location"] },
             successStatus: 204,
             declaredStatuses: [400, 401],

+ 2 - 2
packages/codemode/test/fixtures/opencode-v2-openapi.json

@@ -9310,7 +9310,7 @@
         "summary": "List references"
       }
     },
-    "/experimental/project/{projectID}/copy": {
+    "/api/experimental/project/{projectID}/copy": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.create",
@@ -9536,7 +9536,7 @@
         }
       }
     },
-    "/experimental/project/{projectID}/copy/refresh": {
+    "/api/experimental/project/{projectID}/copy/refresh": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.refresh",

+ 2 - 2
packages/protocol/openapi.json

@@ -10930,7 +10930,7 @@
         "summary": "List references"
       }
     },
-    "/experimental/project/{projectID}/copy": {
+    "/api/experimental/project/{projectID}/copy": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
         }
       }
     },
-    "/experimental/project/{projectID}/copy/refresh": {
+    "/api/experimental/project/{projectID}/copy/refresh": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.refresh",

+ 1 - 1
packages/protocol/src/groups/project-copy.ts

@@ -4,7 +4,7 @@ import { Schema, Struct } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { LocationQuery, locationQueryOpenApi } from "./location.js"
 
-const root = "/experimental/project/:projectID/copy"
+const root = "/api/experimental/project/:projectID/copy"
 
 export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
   {

+ 12 - 2
packages/server/src/process.ts

@@ -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(

+ 26 - 8
packages/server/test/process.test.ts

@@ -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")
   }),
 )

+ 1 - 1
packages/tui/src/app.tsx

@@ -882,7 +882,7 @@ function App(props: { pair?: DialogPairCredentials }) {
       {
         name: "server.pair",
         title: "Pair device",
-        slash: { name: "pair" },
+        slash: { name: "pair", aliases: ["web"] },
         run: () => {
           dialog.replace(() => <DialogPair credentials={props.pair} />)
         },

+ 2 - 2
packages/www/openapi.json

@@ -10930,7 +10930,7 @@
         "summary": "List references"
       }
     },
-    "/experimental/project/{projectID}/copy": {
+    "/api/experimental/project/{projectID}/copy": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
         }
       }
     },
-    "/experimental/project/{projectID}/copy/refresh": {
+    "/api/experimental/project/{projectID}/copy/refresh": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.refresh",

+ 2 - 2
packages/www/public/openapi.json

@@ -10930,7 +10930,7 @@
         "summary": "List references"
       }
     },
-    "/experimental/project/{projectID}/copy": {
+    "/api/experimental/project/{projectID}/copy": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.create",
@@ -11156,7 +11156,7 @@
         }
       }
     },
-    "/experimental/project/{projectID}/copy/refresh": {
+    "/api/experimental/project/{projectID}/copy/refresh": {
       "post": {
         "tags": ["projectCopy"],
         "operationId": "v2.projectCopy.refresh",