mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 02:24:10 +08:00
refactor(core): share a browser opener across opencode and tui (#51414)
This commit is contained in:
@@ -119,6 +119,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "11.0.4",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import open from "open"
|
||||
|
||||
export function openUrl(input: string) {
|
||||
const url = URL.canParse(input) ? new URL(input) : undefined
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:"))
|
||||
return Promise.reject(new Error(`Only http and https links can be opened in the browser: ${input}`))
|
||||
return open(url.href)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
|
||||
describe("openUrl", () => {
|
||||
test("rejects values that are not URLs", async () => {
|
||||
await expect(openUrl("not a url")).rejects.toThrow("Only http and https links")
|
||||
await expect(openUrl("")).rejects.toThrow("Only http and https links")
|
||||
})
|
||||
|
||||
test("rejects non-http schemes", async () => {
|
||||
await expect(openUrl("file:///etc/hosts")).rejects.toThrow("Only http and https links")
|
||||
await expect(openUrl("javascript:alert(1)")).rejects.toThrow("Only http and https links")
|
||||
await expect(openUrl("ms-msdt:/id PCWDiagnostic")).rejects.toThrow("Only http and https links")
|
||||
await expect(openUrl("\\\\server\\share\\file.html")).rejects.toThrow("Only http and https links")
|
||||
})
|
||||
})
|
||||
@@ -131,7 +131,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.1.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "catalog:",
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Account } from "@/account/account"
|
||||
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import * as Prompt from "../effect/prompt"
|
||||
import open from "open"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
|
||||
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
|
||||
const openBrowser = (url: string) => Effect.promise(() => openUrl(url).catch(() => undefined))
|
||||
|
||||
const println = (msg: string) => Effect.sync(() => UI.println(msg))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { UI } from "../ui"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import open from "open"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
import { networkInterfaces } from "os"
|
||||
|
||||
function getNetworkIPs() {
|
||||
@@ -72,11 +72,11 @@ export const WebCommand = effectCmd({
|
||||
}
|
||||
|
||||
// Open localhost in browser
|
||||
open(localhostUrl).catch(() => {})
|
||||
openUrl(localhostUrl).catch(() => {})
|
||||
} else {
|
||||
const displayUrl = server.url.toString()
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
|
||||
open(displayUrl).catch(() => {})
|
||||
openUrl(displayUrl).catch(() => {})
|
||||
}
|
||||
|
||||
yield* Effect.never
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import open from "open"
|
||||
|
||||
export interface Interface {
|
||||
readonly open: (url: string) => Effect.Effect<void, Error>
|
||||
@@ -13,7 +13,7 @@ const layer = Layer.succeed(
|
||||
Service.of({
|
||||
open: Effect.fn("McpBrowser.open")(function* (url: string) {
|
||||
const subprocess = yield* Effect.tryPromise({
|
||||
try: () => open(url),
|
||||
try: () => openUrl(url),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) => {
|
||||
|
||||
@@ -125,6 +125,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
if (authorizationUrl.protocol !== "http:" && authorizationUrl.protocol !== "https:")
|
||||
throw new Error(
|
||||
`MCP server "${this.serverUrl}" returned a ${authorizationUrl.protocol} authorization URL; only http and https are supported`,
|
||||
)
|
||||
await this.callbacks.onRedirect(authorizationUrl)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
|
||||
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
||||
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
||||
@@ -279,7 +279,7 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
|
||||
const state = generateState()
|
||||
const callbackPromise = waitForOAuthCallback(state)
|
||||
const url = buildAuthorizeUrl(state)
|
||||
await open(url).catch(() => undefined)
|
||||
await openUrl(url).catch(() => undefined)
|
||||
return {
|
||||
url,
|
||||
instructions:
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
|
||||
const OAUTH_CLIENT_ID = "LOCAL_APPLICATION"
|
||||
const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
||||
@@ -470,7 +470,7 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise<Ho
|
||||
const role = (inputs.role || "").trim() || undefined
|
||||
const url = buildAuthorizeUrl(account, role, state, pkce)
|
||||
const callbackPromise = waitForOAuthCallback(account, pkce, state)
|
||||
await open(url).catch(() => undefined)
|
||||
await openUrl(url).catch(() => undefined)
|
||||
|
||||
return {
|
||||
url,
|
||||
|
||||
@@ -34,6 +34,37 @@ describe("McpOAuthProvider.redirectUrl", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("McpOAuthProvider.redirectToAuthorization", () => {
|
||||
test("forwards http and https authorization URLs", async () => {
|
||||
const seen: string[] = []
|
||||
const provider = new McpOAuthProvider(
|
||||
"test-server",
|
||||
"https://mcp.example.com/mcp",
|
||||
{},
|
||||
{ onRedirect: (url) => void seen.push(url.href) },
|
||||
stubAuth,
|
||||
)
|
||||
await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize?state=1"))
|
||||
await provider.redirectToAuthorization(new URL("http://127.0.0.1:8080/authorize"))
|
||||
expect(seen).toEqual(["https://auth.example.com/authorize?state=1", "http://127.0.0.1:8080/authorize"])
|
||||
})
|
||||
|
||||
test("rejects authorization URLs that are not http or https", async () => {
|
||||
const seen: string[] = []
|
||||
const provider = new McpOAuthProvider(
|
||||
"test-server",
|
||||
"https://mcp.example.com/mcp",
|
||||
{},
|
||||
{ onRedirect: (url) => void seen.push(url.href) },
|
||||
stubAuth,
|
||||
)
|
||||
await expect(provider.redirectToAuthorization(new URL("file:///tmp/authorize"))).rejects.toThrow(
|
||||
"returned a file: authorization URL",
|
||||
)
|
||||
expect(seen).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("McpOAuthProvider.clientMetadata", () => {
|
||||
test("includes redirect_uris from redirectUrl", () => {
|
||||
const provider = makeProvider({ callbackPort: 6620 })
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
|
||||
@@ -63,7 +63,7 @@ import { isDefaultTitle } from "./util/session"
|
||||
import { KVProvider, useKV } from "./context/kv"
|
||||
import * as Model from "./util/model"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
|
||||
import { createTuiApiAdapters } from "./plugin/adapters"
|
||||
@@ -821,7 +821,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
name: "docs.open",
|
||||
title: "Open docs",
|
||||
run: () => {
|
||||
open("https://opencode.ai/docs").catch(() => {})
|
||||
openUrl("https://opencode.ai/docs").catch(() => {})
|
||||
dialog.clear()
|
||||
},
|
||||
category: "System",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { selectedForeground, useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
@@ -21,7 +21,7 @@ export type DialogRetryActionProps = {
|
||||
}
|
||||
|
||||
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||
if (props.link) open(props.link).catch(() => {})
|
||||
if (props.link) openUrl(props.link).catch(() => {})
|
||||
props.onClose?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { openUrl } from "@opencode-ai/core/open"
|
||||
|
||||
export interface LinkProps {
|
||||
href: string
|
||||
@@ -25,7 +25,7 @@ export function Link(props: LinkProps) {
|
||||
width={props.width}
|
||||
wrapMode={props.wrapMode}
|
||||
onMouseUp={() => {
|
||||
open(props.href).catch(() => {})
|
||||
openUrl(props.href).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
|
||||
Reference in New Issue
Block a user