mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
Compare commits
3 Commits
v2
...
auth-commands
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bf98793d9 | |||
| a5cb47f4ce | |||
| ef8685ab77 |
@@ -121,6 +121,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -86,12 +86,37 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Manage authentication",
|
||||
description: "manage AI providers and credentials",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to a well-known authentication provider",
|
||||
Spec.make("list", {
|
||||
description: "list providers and credentials",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Well-known provider URL")),
|
||||
...ServerParams,
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("login", {
|
||||
description: "log in to a provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID, name, or well-known provider URL"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "log out from a configured provider",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import type { FormAnswer, FormField, FormFields } from "@opencode-ai/client"
|
||||
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
|
||||
const skip = Symbol("skip")
|
||||
const custom = Symbol("custom")
|
||||
|
||||
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
|
||||
if (!fields) return undefined
|
||||
yield* requireInteractive("Authentication form input requires an interactive terminal")
|
||||
const answer: FormAnswer = {}
|
||||
for (const field of fields) {
|
||||
if (!active(field, answer)) continue
|
||||
const value = yield* answerField(field)
|
||||
if (value !== undefined) answer[field.key] = value
|
||||
}
|
||||
return answer
|
||||
})
|
||||
|
||||
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
|
||||
yield* requireInteractive("API key input requires an interactive terminal")
|
||||
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
|
||||
})
|
||||
|
||||
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
|
||||
const message = field.title ?? field.key
|
||||
if (field.description) log.info(field.description)
|
||||
if (field.type === "external") {
|
||||
log.info(field.url)
|
||||
yield* openUrl(field.url)
|
||||
const acknowledged = yield* prompt<boolean>(() =>
|
||||
confirm({ message: message || "Continue after completing this step?", initialValue: true }),
|
||||
)
|
||||
if (!acknowledged) return yield* Effect.fail(new Error(`${message || "External step"} is required`))
|
||||
return true
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (field.required) return yield* prompt<boolean>(() => confirm({ message, initialValue: field.default ?? true }))
|
||||
const options: Array<Option<boolean | typeof skip>> = [
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
{ value: skip, label: "Skip" },
|
||||
]
|
||||
const value = yield* prompt<boolean | typeof skip>(() =>
|
||||
select<boolean | typeof skip>({
|
||||
message,
|
||||
options,
|
||||
initialValue: field.default ?? skip,
|
||||
}),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
return value
|
||||
}
|
||||
if (field.type === "multiselect") {
|
||||
const options: Array<Option<string | typeof custom>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type another value" })
|
||||
const values = yield* prompt<Array<string | typeof custom>>(() =>
|
||||
multiselect<string | typeof custom>({
|
||||
message,
|
||||
options,
|
||||
initialValues: field.default,
|
||||
required: field.required || (field.minItems ?? 0) > 0,
|
||||
}),
|
||||
)
|
||||
const selected = values.filter((value): value is string => value !== custom)
|
||||
if (values.includes(custom)) {
|
||||
selected.push(yield* prompt<string>(() => text({ message: "Enter value", validate: required })))
|
||||
}
|
||||
const invalid = validateMultiselect(field, selected)
|
||||
if (invalid) return yield* Effect.fail(new Error(invalid))
|
||||
return selected
|
||||
}
|
||||
if (field.type === "string" && field.options) {
|
||||
const options: Array<Option<string | typeof custom | typeof skip>> = field.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
hint: option.description,
|
||||
}))
|
||||
if (field.custom) options.push({ value: custom, label: "Type your own answer" })
|
||||
if (!field.required) options.push({ value: skip, label: "Skip" })
|
||||
const value = yield* prompt<string | typeof custom | typeof skip>(() =>
|
||||
select<string | typeof custom | typeof skip>({ message, options, initialValue: field.default }),
|
||||
)
|
||||
if (value === skip) return undefined
|
||||
if (value !== custom) return value
|
||||
}
|
||||
const value = yield* prompt<string>(() =>
|
||||
text({
|
||||
message,
|
||||
placeholder: field.type === "string" ? field.placeholder : undefined,
|
||||
initialValue: field.default === undefined ? undefined : String(field.default),
|
||||
validate: (input) => validateText(field, input),
|
||||
}),
|
||||
)
|
||||
if (!value && !field.required) return undefined
|
||||
if (field.type === "string") return value
|
||||
return Number(value)
|
||||
})
|
||||
|
||||
function active(field: FormField, answer: FormAnswer) {
|
||||
if (field.type === "external" || !field.when) return true
|
||||
return field.when.every((condition) => {
|
||||
const value = answer[condition.key]
|
||||
if (value === undefined) return false
|
||||
const matches = Array.isArray(value) ? value.includes(String(condition.value)) : value === condition.value
|
||||
return condition.op === "eq" ? matches : !matches
|
||||
})
|
||||
}
|
||||
|
||||
function required(value: string | undefined) {
|
||||
return value ? undefined : "Required"
|
||||
}
|
||||
|
||||
function validateText(field: Exclude<FormField, { type: "boolean" | "external" | "multiselect" }>, value?: string) {
|
||||
if (!value) return field.required ? "Required" : undefined
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return "Expected a number"
|
||||
if (field.type === "integer" && !Number.isInteger(number)) return "Expected an integer"
|
||||
if (typeof field.minimum === "number" && number < field.minimum) return `Must be at least ${field.minimum}`
|
||||
if (typeof field.maximum === "number" && number > field.maximum) return `Must be at most ${field.maximum}`
|
||||
return undefined
|
||||
}
|
||||
if (field.minLength !== undefined && value.length < field.minLength)
|
||||
return `Must be at least ${field.minLength} characters`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength)
|
||||
return `Must be at most ${field.maxLength} characters`
|
||||
if (field.pattern) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(value)) return "Invalid format"
|
||||
} catch {
|
||||
return "Invalid format"
|
||||
}
|
||||
}
|
||||
if (field.format === "uri" && !URL.canParse(value)) return "Expected a URL"
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "Expected an email address"
|
||||
if (field.format === "date") {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return "Expected a date"
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return "Expected a date"
|
||||
}
|
||||
if (field.format === "date-time" && Number.isNaN(Date.parse(value))) return "Expected a date and time"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function validateMultiselect(field: Extract<FormField, { type: "multiselect" }>, value: string[]) {
|
||||
if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}`
|
||||
if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}`
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, loadIntegrations } from "./shared"
|
||||
import { handleCommandErrors } from "../../../ui/prompt"
|
||||
|
||||
export default Runtime.handler(Commands.commands.auth.commands.list, (input) => list(input).pipe(handleCommandErrors))
|
||||
|
||||
const list = Effect.fn("cli.auth.list")(function* (input) {
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const integrations = (yield* loadIntegrations(client)).filter((integration) => integration.connections.length > 0)
|
||||
if (input.format === "json") {
|
||||
process.stdout.write(
|
||||
JSON.stringify(
|
||||
integrations.map((integration) => ({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
connections: integration.connections,
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
) + EOL,
|
||||
)
|
||||
return
|
||||
}
|
||||
const rows = integrations.flatMap((integration) =>
|
||||
integration.connections.map((connection) => ({
|
||||
integration: integration.name,
|
||||
source: connection.type === "credential" ? connection.label : connection.name,
|
||||
type: connection.type === "credential" ? "stored" : "environment",
|
||||
})),
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
process.stdout.write("No authenticated integrations" + EOL)
|
||||
return
|
||||
}
|
||||
const width = Math.max(...rows.map((row) => row.integration.length)) + 2
|
||||
process.stdout.write(
|
||||
rows.map((row) => row.integration.padEnd(width) + row.source.padEnd(28) + row.type).join(EOL) + EOL,
|
||||
)
|
||||
})
|
||||
@@ -1,52 +1,269 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode, type IntegrationCommandStatusOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm, secret } from "./form"
|
||||
import {
|
||||
createClient,
|
||||
connectMethods,
|
||||
loadIntegrations,
|
||||
location,
|
||||
methodID,
|
||||
methodLabel,
|
||||
request,
|
||||
resolveIntegration,
|
||||
resolveMethod,
|
||||
type ConnectMethod,
|
||||
} from "./shared"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
const integrationPriority = new Map([
|
||||
["opencode", 0],
|
||||
["opencode-go", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
["anthropic", 5],
|
||||
["openrouter", 6],
|
||||
["vercel", 7],
|
||||
])
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.login,
|
||||
Effect.fn("cli.auth.login")(function* (input) {
|
||||
process.stdout.write("Logging in..." + EOL + EOL)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
yield* request(() => client.integration.wellknown.add({ url: input.url, location }))
|
||||
const integrationID = input.url.replace(/\/+$/, "")
|
||||
const started = yield* request(() =>
|
||||
client.integration.command.connect({ integrationID, methodID: "login", location }),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel({ integrationID, attemptID: started.data.attemptID, location }),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const status = yield* wait(client, integrationID, started.data.attemptID)
|
||||
if (status.status === "failed") return yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") return yield* Effect.fail(new Error("Authentication expired"))
|
||||
process.stdout.write("Logged in" + EOL)
|
||||
}),
|
||||
Effect.fn("cli.auth.login")((input) =>
|
||||
login({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
method: Option.getOrUndefined(input.method),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const wait = (
|
||||
const login = Effect.fn("cli.auth.login.run")(function* (input: {
|
||||
target?: string
|
||||
method?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Connect an integration")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integration = yield* findIntegration(client, input.target)
|
||||
const methods = connectMethods(integration)
|
||||
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
|
||||
const method = yield* chooseMethod(methods, input.method)
|
||||
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
|
||||
yield* authenticate(client, integration, method, answer)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const findIntegration = Effect.fn("cli.auth.login.integration")(function* (client: OpenCodeClient, target?: string) {
|
||||
if (target && isURL(target)) {
|
||||
const progress = spinner()
|
||||
progress.start("Discovering authentication provider...")
|
||||
yield* request((signal) => client.integration.wellknown.add({ url: target, location }, { signal })).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Authentication provider discovered"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Discovery failed", 1))),
|
||||
)
|
||||
}
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const available = integrations
|
||||
.filter((integration) => connectMethods(integration).length > 0)
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
(integrationPriority.get(a.id) ?? integrationPriority.size) -
|
||||
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: available.map((integration) => {
|
||||
const option = { value: integration.id, label: integration.name, hint: integration.id }
|
||||
if (integration.connections.length > 0) return { ...option, hint: "connected" }
|
||||
if (integration.id === "opencode") return { ...option, hint: "recommended" }
|
||||
return option
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(available, id)
|
||||
})
|
||||
|
||||
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
|
||||
if (target) return yield* resolveMethod(methods, target)
|
||||
if (methods.length === 1) return methods[0]
|
||||
yield* requireInteractive("Pass --method when running without an interactive terminal")
|
||||
const id = yield* prompt<string>(() =>
|
||||
select({
|
||||
message: "Select login method",
|
||||
options: methods.map((method) => ({ value: methodID(method), label: methodLabel(method) })),
|
||||
}),
|
||||
)
|
||||
return yield* resolveMethod(methods, id)
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("cli.auth.login.authenticate")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: ConnectMethod,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
if (method.type === "key") return yield* keyLogin(client, integration, method, answer)
|
||||
if (method.type === "command") return yield* commandLogin(client, integration, method)
|
||||
return yield* oauthLogin(client, integration, method, answer)
|
||||
})
|
||||
|
||||
const keyLogin = Effect.fn("cli.auth.login.key")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "key" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const key = yield* secret(method.label ?? `Enter your ${integration.name} API key`)
|
||||
const progress = spinner()
|
||||
progress.start("Saving credential...")
|
||||
yield* request((signal) =>
|
||||
client.integration.connect.key({ integrationID: integration.id, key, answer, location }, { signal }),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))),
|
||||
)
|
||||
})
|
||||
|
||||
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "oauth" }>,
|
||||
answer?: FormAnswer,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authorization...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.oauth.connect(
|
||||
{ integrationID: integration.id, methodID: method.id, answer, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
const attempt = started.data
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.oauth.cancel(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, location },
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
progress.stop("Authorization started")
|
||||
log.info(attempt.instructions)
|
||||
log.info(attempt.url)
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) yield* openUrl(attempt.url)
|
||||
|
||||
if (attempt.mode === "code") {
|
||||
yield* requireInteractive("This login requires an interactive terminal to enter the authorization code")
|
||||
const code = yield* prompt<string>(() =>
|
||||
text({ message: "Paste the authorization code", validate: (value) => (!value ? "Required" : undefined) }),
|
||||
)
|
||||
const completing = spinner()
|
||||
completing.start("Completing authorization...")
|
||||
yield* request((signal) =>
|
||||
client.integration.oauth.complete(
|
||||
{ integrationID: integration.id, attemptID: attempt.attemptID, code, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => completing.stop(`Connected to ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => completing.stop("Authentication failed", 1))),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const waiting = spinner()
|
||||
waiting.start("Waiting for authorization...")
|
||||
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
|
||||
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
|
||||
)
|
||||
if (status.status === "complete") {
|
||||
waiting.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
waiting.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authorization expired"))
|
||||
})
|
||||
|
||||
const commandLogin = Effect.fn("cli.auth.login.command")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "command" }>,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authentication command...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.command.connect({ integrationID: integration.id, methodID: method.id, location }, { signal }),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.command.cancel(
|
||||
{
|
||||
integrationID: integration.id,
|
||||
attemptID: started.data.attemptID,
|
||||
location,
|
||||
},
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
|
||||
progress.message(message.trim() || "Waiting for authentication command..."),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
if (status.status === "complete") {
|
||||
progress.stop(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
progress.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authentication expired"))
|
||||
})
|
||||
|
||||
const waitForOAuth = Effect.fn("cli.auth.login.oauth.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
shown = false,
|
||||
): Effect.Effect<Exclude<IntegrationCommandStatusOutput["data"], { status: "pending" }>, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* request(() => client.integration.command.status({ integrationID, attemptID, location }))
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.oauth.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
const output = response.data.message?.trim()
|
||||
if (!shown && output) process.stdout.write(output + EOL + EOL)
|
||||
yield* Effect.sleep(500)
|
||||
return yield* wait(client, integrationID, attemptID, shown || !!output)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function request<A>(task: () => Promise<A>) {
|
||||
return Effect.tryPromise({ try: task, catch: (cause) => cause })
|
||||
const waitForCommand = Effect.fn("cli.auth.login.command.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
update: (message: string) => void,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.command.status({ integrationID, attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
if (response.data.message) update(response.data.message)
|
||||
yield* Effect.sleep(500)
|
||||
}
|
||||
})
|
||||
|
||||
function isURL(value: string) {
|
||||
if (!URL.canParse(value)) return false
|
||||
const protocol = new URL(value).protocol
|
||||
return protocol === "http:" || protocol === "https:"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { autocomplete, intro, outro, spinner } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { IntegrationInfo } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { createClient, loadIntegrations, location, request, resolveIntegration } from "./shared"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.logout,
|
||||
Effect.fn("cli.auth.logout")((input) =>
|
||||
logout({
|
||||
target: Option.getOrUndefined(input.target),
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
}).pipe(handlePromptErrors),
|
||||
),
|
||||
)
|
||||
|
||||
const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
|
||||
target?: string
|
||||
server?: string
|
||||
standalone: boolean
|
||||
}) {
|
||||
if (!input.target)
|
||||
yield* requireInteractive("Pass an integration ID or name when running without an interactive terminal")
|
||||
intro("Remove credential")
|
||||
const client = yield* createClient({ server: input.server, standalone: input.standalone })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const integration = yield* chooseIntegration(integrations, input.target)
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
const environment = integration.connections
|
||||
.filter((connection) => connection.type === "env")
|
||||
.map((connection) => connection.name)
|
||||
if (environment.length) {
|
||||
yield* Effect.fail(
|
||||
new Error(
|
||||
`${integration.name} is authenticated through ${environment.join(", ")}; unset the environment variable to disconnect`,
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.fail(new Error(`No stored credentials for ${integration.name}`))
|
||||
}
|
||||
const progress = spinner()
|
||||
progress.start("Removing credential...")
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) =>
|
||||
request((signal) => client.credential.remove({ credentialID: connection.id, location }, { signal })),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Disconnected from ${integration.name}`))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const chooseIntegration = Effect.fn("cli.auth.logout.integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target?: string,
|
||||
) {
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const configured = integrations.filter((integration) =>
|
||||
integration.connections.some((connection) => connection.type === "credential"),
|
||||
)
|
||||
if (configured.length === 0) return yield* Effect.fail(new Error("No stored credentials found"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: configured.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.name,
|
||||
hint: integration.connections
|
||||
.filter((connection) => connection.type === "credential")
|
||||
.map((connection) => connection.label)
|
||||
.join(", "),
|
||||
})),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(configured, id)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode, type IntegrationInfo, type IntegrationMethod, type OpenCodeClient } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export const location = { directory: process.cwd() }
|
||||
|
||||
export const createClient = Effect.fn("cli.auth.client")(function* (input: ServerConnection.Args) {
|
||||
const server = yield* ServerConnection.resolve(input)
|
||||
return OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
|
||||
})
|
||||
|
||||
export function request<A>(run: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({ try: run, catch: (cause) => cause })
|
||||
}
|
||||
|
||||
export const loadIntegrations = Effect.fn("cli.auth.integrations")(function* (client: OpenCodeClient) {
|
||||
// The model endpoint is the existing public readiness boundary for the initial plugin generation.
|
||||
yield* request((signal) => client.model.default({ location }, { signal }))
|
||||
return yield* request((signal) => client.integration.list({ location }, { signal })).pipe(
|
||||
Effect.map((response) => response.data),
|
||||
)
|
||||
})
|
||||
|
||||
export const resolveIntegration = Effect.fn("cli.auth.resolve-integration")(function* (
|
||||
integrations: IntegrationInfo[],
|
||||
target: string,
|
||||
) {
|
||||
const normalized = target.replace(/\/+$/, "")
|
||||
const byID = integrations.find((integration) => integration.id === normalized)
|
||||
if (byID) return byID
|
||||
const matches = integrations.filter((integration) => integration.name.toLowerCase() === normalized.toLowerCase())
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) {
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Integration name "${target}" is ambiguous: ${matches.map((integration) => integration.id).join(", ")}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return yield* Effect.fail(new Error(`Integration not found: ${target}`))
|
||||
})
|
||||
|
||||
export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
|
||||
|
||||
export function connectMethods(integration: IntegrationInfo) {
|
||||
return integration.methods
|
||||
.filter((method): method is ConnectMethod => method.type !== "env")
|
||||
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
|
||||
}
|
||||
|
||||
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
|
||||
const normalized = target.toLowerCase()
|
||||
const matches = methods.filter((method) => {
|
||||
if (method.type === "key") return normalized === "key" || method.label?.toLowerCase() === normalized
|
||||
return method.id === target || method.label.toLowerCase() === normalized
|
||||
})
|
||||
if (matches.length === 1) return matches[0]
|
||||
if (matches.length > 1) return yield* Effect.fail(new Error(`Authentication method "${target}" is ambiguous`))
|
||||
const available = methods.map(methodID).join(", ")
|
||||
return yield* Effect.fail(
|
||||
new Error(`Authentication method not found: ${target}${available ? `. Available: ${available}` : ""}`),
|
||||
)
|
||||
})
|
||||
|
||||
export function methodID(method: ConnectMethod) {
|
||||
return method.type === "key" ? "key" : method.id
|
||||
}
|
||||
|
||||
export function methodLabel(method: ConnectMethod) {
|
||||
if (method.type === "key") return method.label ?? "API key"
|
||||
return method.label
|
||||
}
|
||||
@@ -19,7 +19,9 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
acp: () => import("./commands/handlers/acp"),
|
||||
api: () => import("./commands/handlers/api"),
|
||||
auth: {
|
||||
list: () => import("./commands/handlers/auth/list"),
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
logout: () => import("./commands/handlers/auth/logout"),
|
||||
},
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cancel, isCancel, log, outro } from "@clack/prompts"
|
||||
import { Effect } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
|
||||
export class CancelledError extends Error {
|
||||
constructor() {
|
||||
super("Cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
export function prompt<A>(run: () => Promise<A | symbol>) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const value = await run()
|
||||
if (isCancel(value)) throw new CancelledError()
|
||||
return value as A
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
export function requireInteractive(message: string) {
|
||||
if (process.stdin.isTTY && process.stdout.isTTY) return Effect.void
|
||||
return Effect.fail(new Error(message))
|
||||
}
|
||||
|
||||
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
|
||||
const { default: open } = yield* Effect.promise(() => import("open"))
|
||||
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return effect.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof CancelledError,
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
cancel("Cancelled")
|
||||
process.exitCode = 130
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
log.error(message(error))
|
||||
outro("Failed")
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function handleCommandErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return effect.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(message(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -1,20 +1,267 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
|
||||
describe("auth command", () => {
|
||||
test("registers login", async () => {
|
||||
const [auth, login] = await Promise.all([cli(["auth", "--help"]), cli(["auth", "login", "--help"])])
|
||||
test("registers authentication commands", async () => {
|
||||
const [auth, list, login, logout] = await Promise.all([
|
||||
cli(["auth", "--help"]),
|
||||
cli(["auth", "list", "--help"]),
|
||||
cli(["auth", "login", "--help"]),
|
||||
cli(["auth", "logout", "--help"]),
|
||||
])
|
||||
|
||||
expect(auth.exitCode).toBe(0)
|
||||
expect(auth.stdout).toContain("list")
|
||||
expect(auth.stdout).toContain("login")
|
||||
expect(auth.stdout).toContain("Log in to a well-known authentication provider")
|
||||
expect(auth.stdout).toContain("logout")
|
||||
expect(auth.stdout).toContain("manage AI providers and credentials")
|
||||
expect(auth.stdout).toContain("list providers and credentials")
|
||||
expect(auth.stdout).toContain("log in to a provider")
|
||||
expect(auth.stdout).toContain("log out from a configured provider")
|
||||
expect(auth.stdout).not.toContain("connect")
|
||||
expect(list.exitCode).toBe(0)
|
||||
expect(list.stdout).toContain("opencode auth list [flags]")
|
||||
expect(list.stdout).toContain("--format")
|
||||
expect(login.exitCode).toBe(0)
|
||||
expect(login.stdout).toContain("opencode auth login [flags] <url>")
|
||||
expect(login.stdout).toContain("Well-known provider URL")
|
||||
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
|
||||
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
|
||||
expect(login.stdout).toContain("--method")
|
||||
expect(logout.exitCode).toBe(0)
|
||||
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>]")
|
||||
})
|
||||
|
||||
test("lists stored and environment connections", async () => {
|
||||
const requests: string[] = []
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
methods: [],
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
{ id: "openai", name: "OpenAI", methods: [], connections: [] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
}, requests)
|
||||
|
||||
const result = await cli(["auth", "list", "--format", "json", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(JSON.parse(result.stdout)).toEqual([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_test", label: "default" },
|
||||
{ type: "env", name: "ANTHROPIC_API_KEY" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(requests.indexOf("/api/model/default")).toBeLessThan(requests.indexOf("/api/integration"))
|
||||
})
|
||||
|
||||
test("runs command authentication without interactive input", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "company",
|
||||
name: "Company",
|
||||
methods: [{ id: "login", type: "command", label: "Company login", command: ["company", "login"] }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command" && request.method === "POST") {
|
||||
return Response.json(located({ attemptID: "con_test", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "GET") {
|
||||
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/company/connect/command/con_test" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "company", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("Connected to Company")
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/company/connect/command" })
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/company/connect/command/con_test" })
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
|
||||
})
|
||||
|
||||
test("completes automatic OAuth authentication", async () => {
|
||||
const requests: Array<{ method: string; path: string }> = []
|
||||
using server = authServer((request, url) => {
|
||||
requests.push({ method: request.method, path: url.pathname })
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
||||
return Response.json(
|
||||
located({
|
||||
attemptID: "con_oauth",
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Authorize OpenAI",
|
||||
mode: "auto",
|
||||
time: { created: 1, expires: 2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
||||
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("https://example.com/authorize")
|
||||
expect(result.stdout).toContain("Connected to OpenAI")
|
||||
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
|
||||
})
|
||||
|
||||
test("settles the OAuth spinner when status polling fails", async () => {
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
|
||||
connections: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
|
||||
return Response.json(
|
||||
located({
|
||||
attemptID: "con_oauth",
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Authorize OpenAI",
|
||||
mode: "auto",
|
||||
time: { created: 1, expires: 2 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
|
||||
return new Response("Unavailable", { status: 500 })
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Authentication failed")
|
||||
expect(result.stdout).toContain("Failed")
|
||||
expect(result.stdout).not.toContain("\n at ")
|
||||
})
|
||||
|
||||
test("removes stored credentials", async () => {
|
||||
const removed: string[] = []
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
located([
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_test", label: "default" }],
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/api/credential/cred_test" && request.method === "DELETE") {
|
||||
removed.push("cred_test")
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
})
|
||||
|
||||
const result = await cli(["auth", "logout", "anthropic", "--server", server.url.toString()])
|
||||
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
|
||||
expect(result.stdout).toContain("Disconnected from Anthropic")
|
||||
expect(removed).toEqual(["cred_test"])
|
||||
})
|
||||
|
||||
test("requires a target outside an interactive terminal", async () => {
|
||||
const result = await cli(["auth", "login"])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Pass an integration ID or name")
|
||||
expect(result.stdout).not.toContain("Background service failed to start")
|
||||
})
|
||||
|
||||
test("reports list connection failures without a stack trace", async () => {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response("Unavailable", { status: 503 }) })
|
||||
const result = await cli(["auth", "list", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toBe("")
|
||||
expect(result.stderr).toContain("did not provide a compatible V2 health response")
|
||||
expect(result.stderr).not.toContain("\n at ")
|
||||
})
|
||||
})
|
||||
|
||||
function authServer(fetch: (request: Request, url: URL) => Response | Promise<Response>, requests?: string[]) {
|
||||
return Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests?.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return health()
|
||||
if (url.pathname === "/api/model/default") return Response.json(located(null))
|
||||
return fetch(request, url)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function health() {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
|
||||
function located<T>(data: T) {
|
||||
return {
|
||||
location: {
|
||||
directory: process.cwd(),
|
||||
project: { id: "project", directory: process.cwd(), canonical: process.cwd() },
|
||||
},
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
async function cli(args: string[]) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
|
||||
Reference in New Issue
Block a user