feat: add server link sharing

This commit is contained in:
Dax Raad 2026-07-08 18:41:08 -04:00
parent ec8eea7cbe
commit 7698a5e6ac
25 changed files with 1533 additions and 1141 deletions

View file

@ -119,6 +119,7 @@
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",
@ -1016,6 +1017,7 @@
"remeda": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"uqr": "0.1.3",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@ -5950,6 +5952,8 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="],

View file

@ -50,7 +50,8 @@
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2"
"strip-ansi": "7.1.2",
"uqr": "0.1.3"
},
"devDependencies": {
"@opencode-ai/script": "workspace:*",

View file

@ -225,6 +225,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
}),
],
}),
Spec.make("link", { description: "Show server connection information" }),
Spec.make("serve", {
description: "Start the v2 API server",
params: {

View file

@ -0,0 +1,43 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { OpenCode } from "@opencode-ai/client/promise"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServiceConfig } from "../../services/service-config"
export default Runtime.handler(
Commands.commands.link,
Effect.fn("cli.link")(function* () {
const transport = yield* Service.start(yield* ServiceConfig.options())
const password = yield* ServiceConfig.password()
const server = yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: transport.url, headers: transport.headers }).server.get(),
)
const info = { urls: server.urls, username: "opencode", password }
process.stdout.write(
[
"",
` URLs ${info.urls[0] ?? "(none)"}`,
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
"",
" Scan to connect",
"",
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
].join(EOL) + EOL,
)
const hostname = new URL(transport.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
)
}),
)

View file

@ -36,6 +36,7 @@ const Handlers = Runtime.handlers(Commands, {
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
link: () => import("./commands/handlers/link"),
service: {
start: () => import("./commands/handlers/service/start"),
restart: () => import("./commands/handlers/service/restart"),

View file

@ -30,6 +30,7 @@ export function runTui(
return yield* run({
client: createOpencodeClient({ ...options, directory }),
api,
link: linkCredentials(transport),
discover: discover
? async () => {
const next = await discover()
@ -64,3 +65,12 @@ export function runTui(
})
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}
function linkCredentials(transport: Service.Transport) {
const authorization = new Headers(transport.headers).get("authorization")
if (!authorization?.startsWith("Basic ")) return { username: "opencode", password: "" }
const value = atob(authorization.slice("Basic ".length))
const separator = value.indexOf(":")
if (separator === -1) return { username: "opencode", password: "" }
return { username: value.slice(0, separator), password: value.slice(separator + 1) }
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import type {
HealthGetOutput,
ServerGetOutput,
LocationGetInput,
LocationGetOutput,
AgentListInput,
@ -328,6 +329,13 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
server: {
get: (requestOptions?: RequestOptions) =>
request<ServerGetOutput>(
{ method: "GET", path: `/api/server`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
},
location: {
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
request<LocationGetOutput>(

View file

@ -159,6 +159,8 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
export type ServerGetOutput = { readonly urls: ReadonlyArray<string> }
export type LocationGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

View file

@ -6,6 +6,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client)).toEqual([
"health",
"server",
"location",
"agent",
"plugin",
@ -45,6 +46,21 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("server.get uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ urls: ["http://192.168.1.10:4096"] })
},
})
expect(await client.server.get()).toEqual({ urls: ["http://192.168.1.10:4096"] })
expect(request?.method).toBe("GET")
expect(request?.url).toBe("http://localhost:3000/api/server")
})
test("MCP resource catalog uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({

View file

@ -16,6 +16,7 @@ import type { Definition } from "@opencode-ai/schema/event"
import { AgentGroup } from "./groups/agent.js"
import { PluginGroup } from "./groups/plugin.js"
import { HealthGroup } from "./groups/health.js"
import { ServerGroup } from "./groups/server.js"
import { DebugGroup } from "./groups/debug.js"
import { PtyGroup } from "./groups/pty.js"
import { ShellGroup } from "./groups/shell.js"
@ -82,6 +83,7 @@ type ApiGroups<
Event extends HttpApiGroup.Any,
> =
| typeof HealthGroup
| typeof ServerGroup
| typeof DebugGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
@ -143,6 +145,7 @@ const makeApiFromGroup = <
> =>
HttpApi.make("server")
.add(HealthGroup)
.add(ServerGroup)
.add(LocationGroup.middleware(locationMiddleware))
.add(AgentGroup.middleware(locationMiddleware))
.add(PluginGroup.middleware(locationMiddleware))

View file

@ -34,6 +34,7 @@ export const ClientApi: ClientApiShape = makeDefaultApi({
export const groupNames = {
"server.health": "health",
"server.server": "server",
"server.debug": "debug",
"server.location": "location",
"server.agent": "agent",

View file

@ -0,0 +1,16 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const ServerGroup = HttpApiGroup.make("server.server")
.add(
HttpApiEndpoint.get("server.get", "/api/server", {
success: Schema.Struct({ urls: Schema.Array(Schema.String) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.server.get",
summary: "Get server information",
description: "Return the URLs that can be used to connect to this server.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "server" }))

View file

@ -362,6 +362,8 @@ import type {
V2QuestionRequestListResponses,
V2ReferenceListErrors,
V2ReferenceListResponses,
V2ServerGetErrors,
V2ServerGetResponses,
V2SessionActiveErrors,
V2SessionActiveResponses,
V2SessionBackgroundErrors,
@ -5113,6 +5115,20 @@ export class Health extends HeyApiClient {
}
}
export class Server extends HeyApiClient {
/**
* Get server information
*
* Return the URLs that can be used to connect to this server.
*/
public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this.client).get<V2ServerGetResponses, V2ServerGetErrors, ThrowOnError>({
url: "/api/server",
...options,
})
}
}
export class Location extends HeyApiClient {
/**
* Get location
@ -8259,6 +8275,11 @@ export class V2 extends HeyApiClient {
return (this._health ??= new Health({ client: this.client }))
}
private _server?: Server
get server(): Server {
return (this._server ??= new Server({ client: this.client }))
}
private _location?: Location
get location(): Location {
return (this._location ??= new Location({ client: this.client }))

View file

@ -15154,6 +15154,37 @@ export type V2HealthGetResponses = {
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
export type V2ServerGetData = {
body?: never
path?: never
query?: never
url: "/api/server"
}
export type V2ServerGetErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestErrorV2
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ServerGetError = V2ServerGetErrors[keyof V2ServerGetErrors]
export type V2ServerGetResponses = {
/**
* Success
*/
200: {
urls: Array<string>
}
}
export type V2ServerGetResponse = V2ServerGetResponses[keyof V2ServerGetResponses]
export type V2LocationGetData = {
body?: never
path?: never

View file

@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { PluginHandler } from "./handlers/plugin"
import { HealthHandler } from "./handlers/health"
import { ServerHandler } from "./handlers/server"
import { DebugHandler } from "./handlers/debug"
import { PtyHandler } from "./handlers/pty"
import { ShellHandler } from "./handlers/shell"
@ -28,6 +29,7 @@ import { VcsHandler } from "./handlers/vcs"
export const handlers = Layer.mergeAll(
HealthHandler,
ServerHandler,
DebugHandler,
LocationHandler,
AgentHandler,

View file

@ -0,0 +1,13 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
handlers.handle("server.get", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return { urls: info.urls() }
}),
),
)

View file

@ -8,6 +8,7 @@ import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
export type Options = {
readonly hostname: string
@ -48,7 +49,12 @@ function listen(options: Options) {
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
createRoutes(password).pipe(
createRoutes(password, () => {
const address = server.address()
if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
}).pipe(
Layer.flatMap((context) =>
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
),

View file

@ -30,6 +30,7 @@ import { PtyEnvironment } from "./pty-environment"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
const applicationServices = LayerNode.group([
Database.node,
@ -50,11 +51,12 @@ const applicationServices = LayerNode.group([
LocationServiceMap.node,
])
export function createRoutes(password?: string) {
export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray<string> = () => []) {
return makeRoutes(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer,
serviceURLs,
)
}
@ -62,7 +64,10 @@ export function createEmbeddedRoutes() {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
}
function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
serviceURLs: () => ReadonlyArray<string> = () => [],
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[SessionExecution.node, SessionExecutionLocal.node],
@ -87,7 +92,10 @@ function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config
return serviceLayer.pipe(
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context))
const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)),
ServerInfo.layer(serviceURLs),
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))),
Layer.provide(formLocationLayer),

View file

@ -0,0 +1,32 @@
import { Context, Layer } from "effect"
import { networkInterfaces } from "node:os"
export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()(
"@opencode-ai/server/ServerInfo",
) {}
export function layer(urls: () => ReadonlyArray<string>) {
return Layer.succeed(Service, Service.of({ urls }))
}
export function connectionURLs(value: string, requestedHostname?: string) {
const url = new URL(value)
const hostname = requestedHostname ?? url.hostname
const family = hostname === "0.0.0.0" ? "IPv4" : hostname === "::" || hostname === "[::]" ? "IPv6" : undefined
if (family === undefined) return [value]
return [
...new Set(
Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry) => !entry.internal && entry.family === family)
.map((entry) => {
const result = new URL(value)
result.hostname = family === "IPv6" ? `[${entry.address}]` : entry.address
return result.toString().replace(/\/$/, "")
}),
),
]
}
export * as ServerInfo from "./server-info"

View file

@ -69,8 +69,9 @@
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"solid-js": "catalog:"
"uqr": "0.1.3"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",

View file

@ -45,6 +45,7 @@ import { useConnected } from "./component/use-connected"
import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogDebug } from "./component/dialog-debug"
import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@ -121,6 +122,7 @@ const appBindingCommands = [
"provider.connect",
"console.org.switch",
"opencode.status",
"server.link",
"opencode.debug",
"theme.switch",
"theme.switch_mode",
@ -146,6 +148,7 @@ export type TuiInput = {
api: OpenCodeClient
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
reload?: () => Promise<void>
link?: DialogLinkCredentials
args: Args
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
@ -335,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
link={input.link}
/>
</LocationProvider>
</EditorContextProvider>
@ -380,7 +384,11 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
function App(props: {
onSnapshot?: () => Promise<string[]>
pluginHost: TuiPluginHost
link?: DialogLinkCredentials
}) {
const log = useLog({ component: "app" })
const startup = useTuiStartup()
const tuiConfig = useTuiConfig()
@ -813,6 +821,15 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
},
category: "System",
},
{
name: "server.link",
title: "Show server connection information",
slashName: "link",
run: () => {
dialog.replace(() => <DialogLink credentials={props.link} />)
},
category: "System",
},
...(sdk.reload
? [
{

View file

@ -0,0 +1,126 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { renderUnicodeCompact } from "uqr"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { errorMessage } from "../util/error"
export type DialogLinkCredentials = {
readonly username: string
readonly password: string
}
export function DialogLink(props: { credentials?: DialogLinkCredentials }) {
const sdk = useSDK()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
dialog.setSize("large")
dialog.setCentered(true)
const [server] = createResource(() =>
sdk.client.v2.server
.get({ throwOnError: true })
.then((result) => result.data)
.catch((error) => {
setLoadError(error)
return undefined
}),
)
const info = createMemo(() => {
const current = server()
if (!current) return
return {
urls: current.urls,
username: props.credentials?.username ?? "opencode",
password: props.credentials?.password ?? "",
}
})
const horizontal = createMemo(() => dimensions().width >= 96)
const content = () => {
const value = info()
if (!value) return
return (
<box
flexDirection={horizontal() ? "row" : "column"}
alignItems={horizontal() ? "flex-start" : "center"}
gap={2}
>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box>
<text fg={theme.textMuted}>URLs</text>
<For each={value.urls}>{(url) => <text fg={theme.text}>{url}</text>}</For>
</box>
<box>
<text fg={theme.textMuted}>Username</text>
<text fg={theme.text}>{value.username}</text>
</box>
<box>
<text fg={theme.textMuted}>Password</text>
<text
fg={passwordHover() ? theme.text : theme.textMuted}
wrapMode="word"
onMouseOver={() => setPasswordHover(true)}
onMouseOut={() => setPasswordHover(false)}
onMouseUp={() => setShowPassword((current) => !current)}
>
{showPassword() ? value.password : "************"}
</text>
</box>
<Show
when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}
>
<text fg={theme.textMuted} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text>
</Show>
</box>
<box
width={horizontal() ? undefined : "100%"}
flexGrow={horizontal() ? 1 : 0}
flexShrink={0}
alignItems={horizontal() ? "flex-end" : "center"}
>
<text fg={theme.text}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
</box>
</box>
)
}
return (
<box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
Link
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<Show when={loadError()}>
{(error) => <text fg={theme.error}>{errorMessage(error())}</text>}
</Show>
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information...</text>}>
<Show
when={dimensions().height >= 36}
fallback={
<scrollbox
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
scrollbarOptions={{ visible: false }}
>
{content()}
</scrollbox>
}
>
{content()}
</Show>
</Show>
</box>
)
}

View file

@ -11,6 +11,7 @@ import { useClipboard } from "../context/clipboard"
export function Dialog(
props: ParentProps<{
size?: "medium" | "large" | "xlarge"
centered?: boolean
onClose: () => void
}>,
) {
@ -40,9 +41,10 @@ export function Dialog(
width={dimensions().width}
height={dimensions().height}
alignItems="center"
justifyContent={props.centered ? "center" : undefined}
position="absolute"
zIndex={3000}
paddingTop={dimensions().height / 4}
paddingTop={props.centered ? 0 : dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
@ -73,6 +75,7 @@ function init() {
onClose?: () => void
}[],
size: "medium" as "medium" | "large" | "xlarge",
centered: false,
})
const renderer = useRenderer()
@ -143,6 +146,7 @@ function init() {
}
batch(() => {
setStore("size", "medium")
setStore("centered", false)
setStore("stack", [])
})
refocus()
@ -156,6 +160,7 @@ function init() {
if (item.onClose) item.onClose()
}
setStore("size", "medium")
setStore("centered", false)
setStore("stack", [
{
element: input,
@ -169,9 +174,15 @@ function init() {
get size() {
return store.size
},
get centered() {
return store.centered
},
setSize(size: "medium" | "large" | "xlarge") {
setStore("size", size)
},
setCentered(centered: boolean) {
setStore("centered", centered)
},
}
}
@ -213,7 +224,7 @@ export function DialogProvider(props: ParentProps) {
onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>