feat: add server link sharing
This commit is contained in:
@@ -119,6 +119,7 @@
|
|||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
|
"uqr": "0.1.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
@@ -1016,6 +1017,7 @@
|
|||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
|
"uqr": "0.1.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@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=="],
|
"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=="],
|
"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=="],
|
"urijs": ["urijs@1.19.11", "", {}, "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="],
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "catalog:",
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2"
|
"strip-ansi": "7.1.2",
|
||||||
|
"uqr": "0.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
|
|||||||
@@ -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", {
|
Spec.make("serve", {
|
||||||
description: "Start the v2 API server",
|
description: "Start the v2 API server",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -36,6 +36,7 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
migrate: () => import("./commands/handlers/migrate"),
|
migrate: () => import("./commands/handlers/migrate"),
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
|
link: () => import("./commands/handlers/link"),
|
||||||
service: {
|
service: {
|
||||||
start: () => import("./commands/handlers/service/start"),
|
start: () => import("./commands/handlers/service/start"),
|
||||||
restart: () => import("./commands/handlers/service/restart"),
|
restart: () => import("./commands/handlers/service/restart"),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function runTui(
|
|||||||
return yield* run({
|
return yield* run({
|
||||||
client: createOpencodeClient({ ...options, directory }),
|
client: createOpencodeClient({ ...options, directory }),
|
||||||
api,
|
api,
|
||||||
|
link: linkCredentials(transport),
|
||||||
discover: discover
|
discover: discover
|
||||||
? async () => {
|
? async () => {
|
||||||
const next = await discover()
|
const next = await discover()
|
||||||
@@ -64,3 +65,12 @@ export function runTui(
|
|||||||
})
|
})
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
}).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
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
HealthGetOutput,
|
HealthGetOutput,
|
||||||
|
ServerGetOutput,
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
AgentListInput,
|
AgentListInput,
|
||||||
@@ -328,6 +329,13 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
get: (requestOptions?: RequestOptions) =>
|
||||||
|
request<ServerGetOutput>(
|
||||||
|
{ method: "GET", path: `/api/server`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
|
requestOptions,
|
||||||
|
),
|
||||||
|
},
|
||||||
location: {
|
location: {
|
||||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||||
request<LocationGetOutput>(
|
request<LocationGetOutput>(
|
||||||
|
|||||||
@@ -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 HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||||
|
|
||||||
|
export type ServerGetOutput = { readonly urls: ReadonlyArray<string> }
|
||||||
|
|
||||||
export type LocationGetInput = {
|
export type LocationGetInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
|
|
||||||
expect(Object.keys(client)).toEqual([
|
expect(Object.keys(client)).toEqual([
|
||||||
"health",
|
"health",
|
||||||
|
"server",
|
||||||
"location",
|
"location",
|
||||||
"agent",
|
"agent",
|
||||||
"plugin",
|
"plugin",
|
||||||
@@ -45,6 +46,21 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
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 () => {
|
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||||
let request: Request | undefined
|
let request: Request | undefined
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { Definition } from "@opencode-ai/schema/event"
|
|||||||
import { AgentGroup } from "./groups/agent.js"
|
import { AgentGroup } from "./groups/agent.js"
|
||||||
import { PluginGroup } from "./groups/plugin.js"
|
import { PluginGroup } from "./groups/plugin.js"
|
||||||
import { HealthGroup } from "./groups/health.js"
|
import { HealthGroup } from "./groups/health.js"
|
||||||
|
import { ServerGroup } from "./groups/server.js"
|
||||||
import { DebugGroup } from "./groups/debug.js"
|
import { DebugGroup } from "./groups/debug.js"
|
||||||
import { PtyGroup } from "./groups/pty.js"
|
import { PtyGroup } from "./groups/pty.js"
|
||||||
import { ShellGroup } from "./groups/shell.js"
|
import { ShellGroup } from "./groups/shell.js"
|
||||||
@@ -82,6 +83,7 @@ type ApiGroups<
|
|||||||
Event extends HttpApiGroup.Any,
|
Event extends HttpApiGroup.Any,
|
||||||
> =
|
> =
|
||||||
| typeof HealthGroup
|
| typeof HealthGroup
|
||||||
|
| typeof ServerGroup
|
||||||
| typeof DebugGroup
|
| typeof DebugGroup
|
||||||
| LocationGroups<LocationId>
|
| LocationGroups<LocationId>
|
||||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||||
@@ -143,6 +145,7 @@ const makeApiFromGroup = <
|
|||||||
> =>
|
> =>
|
||||||
HttpApi.make("server")
|
HttpApi.make("server")
|
||||||
.add(HealthGroup)
|
.add(HealthGroup)
|
||||||
|
.add(ServerGroup)
|
||||||
.add(LocationGroup.middleware(locationMiddleware))
|
.add(LocationGroup.middleware(locationMiddleware))
|
||||||
.add(AgentGroup.middleware(locationMiddleware))
|
.add(AgentGroup.middleware(locationMiddleware))
|
||||||
.add(PluginGroup.middleware(locationMiddleware))
|
.add(PluginGroup.middleware(locationMiddleware))
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const ClientApi: ClientApiShape = makeDefaultApi({
|
|||||||
|
|
||||||
export const groupNames = {
|
export const groupNames = {
|
||||||
"server.health": "health",
|
"server.health": "health",
|
||||||
|
"server.server": "server",
|
||||||
"server.debug": "debug",
|
"server.debug": "debug",
|
||||||
"server.location": "location",
|
"server.location": "location",
|
||||||
"server.agent": "agent",
|
"server.agent": "agent",
|
||||||
|
|||||||
@@ -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" }))
|
||||||
@@ -362,6 +362,8 @@ import type {
|
|||||||
V2QuestionRequestListResponses,
|
V2QuestionRequestListResponses,
|
||||||
V2ReferenceListErrors,
|
V2ReferenceListErrors,
|
||||||
V2ReferenceListResponses,
|
V2ReferenceListResponses,
|
||||||
|
V2ServerGetErrors,
|
||||||
|
V2ServerGetResponses,
|
||||||
V2SessionActiveErrors,
|
V2SessionActiveErrors,
|
||||||
V2SessionActiveResponses,
|
V2SessionActiveResponses,
|
||||||
V2SessionBackgroundErrors,
|
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 {
|
export class Location extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Get location
|
* Get location
|
||||||
@@ -8259,6 +8275,11 @@ export class V2 extends HeyApiClient {
|
|||||||
return (this._health ??= new Health({ client: this.client }))
|
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
|
private _location?: Location
|
||||||
get location(): Location {
|
get location(): Location {
|
||||||
return (this._location ??= new Location({ client: this.client }))
|
return (this._location ??= new Location({ client: this.client }))
|
||||||
|
|||||||
@@ -15154,6 +15154,37 @@ export type V2HealthGetResponses = {
|
|||||||
|
|
||||||
export type V2HealthGetResponse = V2HealthGetResponses[keyof 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 = {
|
export type V2LocationGetData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event"
|
|||||||
import { AgentHandler } from "./handlers/agent"
|
import { AgentHandler } from "./handlers/agent"
|
||||||
import { PluginHandler } from "./handlers/plugin"
|
import { PluginHandler } from "./handlers/plugin"
|
||||||
import { HealthHandler } from "./handlers/health"
|
import { HealthHandler } from "./handlers/health"
|
||||||
|
import { ServerHandler } from "./handlers/server"
|
||||||
import { DebugHandler } from "./handlers/debug"
|
import { DebugHandler } from "./handlers/debug"
|
||||||
import { PtyHandler } from "./handlers/pty"
|
import { PtyHandler } from "./handlers/pty"
|
||||||
import { ShellHandler } from "./handlers/shell"
|
import { ShellHandler } from "./handlers/shell"
|
||||||
@@ -28,6 +29,7 @@ import { VcsHandler } from "./handlers/vcs"
|
|||||||
|
|
||||||
export const handlers = Layer.mergeAll(
|
export const handlers = Layer.mergeAll(
|
||||||
HealthHandler,
|
HealthHandler,
|
||||||
|
ServerHandler,
|
||||||
DebugHandler,
|
DebugHandler,
|
||||||
LocationHandler,
|
LocationHandler,
|
||||||
AgentHandler,
|
AgentHandler,
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
|||||||
import { createServer } from "node:http"
|
import { createServer } from "node:http"
|
||||||
import { ServerAuth } from "./auth"
|
import { ServerAuth } from "./auth"
|
||||||
import { createRoutes } from "./routes"
|
import { createRoutes } from "./routes"
|
||||||
|
import { ServerInfo } from "./server-info"
|
||||||
|
|
||||||
export type Options = {
|
export type Options = {
|
||||||
readonly hostname: string
|
readonly hostname: string
|
||||||
@@ -48,7 +49,12 @@ function listen(options: Options) {
|
|||||||
function bind(hostname: string, port: number, password: string) {
|
function bind(hostname: string, port: number, password: string) {
|
||||||
const server = createServer()
|
const server = createServer()
|
||||||
return Layer.build(
|
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) =>
|
Layer.flatMap((context) =>
|
||||||
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
|
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { PtyEnvironment } from "./pty-environment"
|
|||||||
import { layer } from "./location"
|
import { layer } from "./location"
|
||||||
import { formLocationLayer } from "./middleware/form-location"
|
import { formLocationLayer } from "./middleware/form-location"
|
||||||
import { sessionLocationLayer } from "./middleware/session-location"
|
import { sessionLocationLayer } from "./middleware/session-location"
|
||||||
|
import { ServerInfo } from "./server-info"
|
||||||
|
|
||||||
const applicationServices = LayerNode.group([
|
const applicationServices = LayerNode.group([
|
||||||
Database.node,
|
Database.node,
|
||||||
@@ -50,11 +51,12 @@ const applicationServices = LayerNode.group([
|
|||||||
LocationServiceMap.node,
|
LocationServiceMap.node,
|
||||||
])
|
])
|
||||||
|
|
||||||
export function createRoutes(password?: string) {
|
export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray<string> = () => []) {
|
||||||
return makeRoutes(
|
return makeRoutes(
|
||||||
password
|
password
|
||||||
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
|
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
|
||||||
: ServerAuth.Config.layer,
|
: ServerAuth.Config.layer,
|
||||||
|
serviceURLs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,10 @@ export function createEmbeddedRoutes() {
|
|||||||
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
|
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 pluginRuntimeCell = PluginRuntime.makeCell()
|
||||||
const replacements: LayerNode.Replacements = [
|
const replacements: LayerNode.Replacements = [
|
||||||
[SessionExecution.node, SessionExecutionLocal.node],
|
[SessionExecution.node, SessionExecutionLocal.node],
|
||||||
@@ -87,7 +92,10 @@ function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config
|
|||||||
return serviceLayer.pipe(
|
return serviceLayer.pipe(
|
||||||
Layer.flatMap((context) => {
|
Layer.flatMap((context) => {
|
||||||
const services = Layer.succeedContext(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(
|
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||||
Layer.provide(handlers.pipe(Layer.provide(services))),
|
Layer.provide(handlers.pipe(Layer.provide(services))),
|
||||||
Layer.provide(formLocationLayer),
|
Layer.provide(formLocationLayer),
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -69,8 +69,9 @@
|
|||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "catalog:",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
"solid-js": "catalog:"
|
"uqr": "0.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { useConnected } from "./component/use-connected"
|
|||||||
import { DialogMcp } from "./component/dialog-mcp"
|
import { DialogMcp } from "./component/dialog-mcp"
|
||||||
import { DialogStatus } from "./component/dialog-status"
|
import { DialogStatus } from "./component/dialog-status"
|
||||||
import { DialogDebug } from "./component/dialog-debug"
|
import { DialogDebug } from "./component/dialog-debug"
|
||||||
|
import { DialogLink, type DialogLinkCredentials } from "./component/dialog-link"
|
||||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||||
import { DialogHelp } from "./ui/dialog-help"
|
import { DialogHelp } from "./ui/dialog-help"
|
||||||
import { DialogAgent } from "./component/dialog-agent"
|
import { DialogAgent } from "./component/dialog-agent"
|
||||||
@@ -121,6 +122,7 @@ const appBindingCommands = [
|
|||||||
"provider.connect",
|
"provider.connect",
|
||||||
"console.org.switch",
|
"console.org.switch",
|
||||||
"opencode.status",
|
"opencode.status",
|
||||||
|
"server.link",
|
||||||
"opencode.debug",
|
"opencode.debug",
|
||||||
"theme.switch",
|
"theme.switch",
|
||||||
"theme.switch_mode",
|
"theme.switch_mode",
|
||||||
@@ -146,6 +148,7 @@ export type TuiInput = {
|
|||||||
api: OpenCodeClient
|
api: OpenCodeClient
|
||||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||||
reload?: () => Promise<void>
|
reload?: () => Promise<void>
|
||||||
|
link?: DialogLinkCredentials
|
||||||
args: Args
|
args: Args
|
||||||
config: TuiConfig.Resolved
|
config: TuiConfig.Resolved
|
||||||
onSnapshot?: () => Promise<string[]>
|
onSnapshot?: () => Promise<string[]>
|
||||||
@@ -335,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
<App
|
<App
|
||||||
onSnapshot={input.onSnapshot}
|
onSnapshot={input.onSnapshot}
|
||||||
pluginHost={input.pluginHost}
|
pluginHost={input.pluginHost}
|
||||||
|
link={input.link}
|
||||||
/>
|
/>
|
||||||
</LocationProvider>
|
</LocationProvider>
|
||||||
</EditorContextProvider>
|
</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 log = useLog({ component: "app" })
|
||||||
const startup = useTuiStartup()
|
const startup = useTuiStartup()
|
||||||
const tuiConfig = useTuiConfig()
|
const tuiConfig = useTuiConfig()
|
||||||
@@ -813,6 +821,15 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
},
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "server.link",
|
||||||
|
title: "Show server connection information",
|
||||||
|
slashName: "link",
|
||||||
|
run: () => {
|
||||||
|
dialog.replace(() => <DialogLink credentials={props.link} />)
|
||||||
|
},
|
||||||
|
category: "System",
|
||||||
|
},
|
||||||
...(sdk.reload
|
...(sdk.reload
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useClipboard } from "../context/clipboard"
|
|||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
size?: "medium" | "large" | "xlarge"
|
size?: "medium" | "large" | "xlarge"
|
||||||
|
centered?: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
@@ -40,9 +41,10 @@ export function Dialog(
|
|||||||
width={dimensions().width}
|
width={dimensions().width}
|
||||||
height={dimensions().height}
|
height={dimensions().height}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
|
justifyContent={props.centered ? "center" : undefined}
|
||||||
position="absolute"
|
position="absolute"
|
||||||
zIndex={3000}
|
zIndex={3000}
|
||||||
paddingTop={dimensions().height / 4}
|
paddingTop={props.centered ? 0 : dimensions().height / 4}
|
||||||
left={0}
|
left={0}
|
||||||
top={0}
|
top={0}
|
||||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||||
@@ -73,6 +75,7 @@ function init() {
|
|||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}[],
|
}[],
|
||||||
size: "medium" as "medium" | "large" | "xlarge",
|
size: "medium" as "medium" | "large" | "xlarge",
|
||||||
|
centered: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
@@ -143,6 +146,7 @@ function init() {
|
|||||||
}
|
}
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("size", "medium")
|
setStore("size", "medium")
|
||||||
|
setStore("centered", false)
|
||||||
setStore("stack", [])
|
setStore("stack", [])
|
||||||
})
|
})
|
||||||
refocus()
|
refocus()
|
||||||
@@ -156,6 +160,7 @@ function init() {
|
|||||||
if (item.onClose) item.onClose()
|
if (item.onClose) item.onClose()
|
||||||
}
|
}
|
||||||
setStore("size", "medium")
|
setStore("size", "medium")
|
||||||
|
setStore("centered", false)
|
||||||
setStore("stack", [
|
setStore("stack", [
|
||||||
{
|
{
|
||||||
element: input,
|
element: input,
|
||||||
@@ -169,9 +174,15 @@ function init() {
|
|||||||
get size() {
|
get size() {
|
||||||
return store.size
|
return store.size
|
||||||
},
|
},
|
||||||
|
get centered() {
|
||||||
|
return store.centered
|
||||||
|
},
|
||||||
setSize(size: "medium" | "large" | "xlarge") {
|
setSize(size: "medium" | "large" | "xlarge") {
|
||||||
setStore("size", size)
|
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}
|
onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined}
|
||||||
>
|
>
|
||||||
<Show when={value.stack.length}>
|
<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}
|
{value.stack.at(-1)!.element}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
Reference in New Issue
Block a user