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

Co-authored-by: Dax Raad <d@ironbay.co>
This commit is contained in:
Brendan Allan
2026-08-12 06:43:07 +08:00
committed by GitHub
parent f1399dcb65
commit 20df2fdba6
19 changed files with 338 additions and 33 deletions
+41
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -267,7 +267,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
Spec.make("pair", { description: "Show server pairing information" }),
Spec.make("serve", {
description: "Start the v2 API 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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,4 @@
declare module "virtual:opencode-app-assets" {
const archive: string
export default archive
}
+68
View File
@@ -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
View File
@@ -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: "",
})
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",