feat(v2/cli): add console login (#35969)
This commit is contained in:
parent
212c9f99ee
commit
19f42f7102
10 changed files with 584 additions and 28 deletions
|
|
@ -75,6 +75,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
description: "Manage OpenCode Console access",
|
||||
commands: [
|
||||
Spec.make("login", {
|
||||
description: "Log in to OpenCode Console",
|
||||
params: {
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("mcp", {
|
||||
description: "Manage MCP (Model Context Protocol) servers",
|
||||
commands: [
|
||||
|
|
|
|||
116
packages/cli/src/commands/handlers/console/login.ts
Normal file
116
packages/cli/src/commands/handlers/console/login.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Cause, Effect, Exit, Option } from "effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../daemon"
|
||||
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
|
||||
|
||||
const integrationID = "opencode"
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.console.commands.login,
|
||||
Effect.fn("cli.console.login")(function* (input) {
|
||||
const timeline = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => createTimelineHost()),
|
||||
(value) => request(() => value.close()).pipe(Effect.ignore),
|
||||
)
|
||||
const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe(
|
||||
Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(exit)) return
|
||||
|
||||
const cancelled = timeline.signal.aborted
|
||||
yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe(
|
||||
Effect.ignore,
|
||||
)
|
||||
process.exitCode = cancelled ? 130 : 1
|
||||
}),
|
||||
)
|
||||
|
||||
const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) {
|
||||
yield* request(() => timeline.intro("Log in"))
|
||||
yield* request(() => timeline.pending("Connecting to OpenCode..."))
|
||||
|
||||
const transport = yield* Daemon.transport({ mode: "shared" })
|
||||
const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
|
||||
const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
|
||||
const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
|
||||
const method = yield* required(
|
||||
integration.methods.find((candidate) => candidate.type === "oauth"),
|
||||
"OpenCode Console login is unavailable",
|
||||
)
|
||||
|
||||
yield* request(() => timeline.pending("Starting authorization..."))
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.connect.oauth(
|
||||
{
|
||||
integrationID,
|
||||
methodID: method.id,
|
||||
inputs: server ? { server } : {},
|
||||
location,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
const attempt = started.data
|
||||
yield* Effect.addFinalizer(() =>
|
||||
request(() =>
|
||||
client.integration.attempt.cancel(
|
||||
{ attemptID: attempt.attemptID, location },
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login"))
|
||||
|
||||
yield* request(() => timeline.item(`Go to: ${attempt.url}`))
|
||||
yield* request(() => timeline.item(attempt.instructions))
|
||||
yield* request(async () => {
|
||||
const { default: open } = await import("open")
|
||||
await open(attempt.url)
|
||||
}).pipe(Effect.ignore)
|
||||
yield* request(() => timeline.pending("Waiting for authorization..."))
|
||||
|
||||
const status = yield* waitForConsoleLogin(client, attempt.attemptID)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
if (status.status === "expired") yield* Effect.fail(new Error("Device code expired"))
|
||||
|
||||
yield* request(() => timeline.success("Connected to OpenCode Console"))
|
||||
yield* request(() => timeline.outro("Done"))
|
||||
})
|
||||
|
||||
const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* (
|
||||
client: OpenCodeClient,
|
||||
attemptID: string,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* request((signal) =>
|
||||
client.integration.attempt.status({ attemptID, location }, { signal }),
|
||||
)
|
||||
if (response.data.status !== "pending") return response.data
|
||||
yield* Effect.sleep(500)
|
||||
}
|
||||
})
|
||||
|
||||
function request<A>(task: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({
|
||||
try: task,
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function required<A>(value: A | null | undefined, message: string) {
|
||||
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
|
||||
}
|
||||
|
||||
function errorMessage(cause: Cause.Cause<unknown>) {
|
||||
const error = Cause.squash(cause)
|
||||
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)
|
||||
}
|
||||
|
|
@ -24,6 +24,9 @@ const Handlers = Runtime.handlers(Commands, {
|
|||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
},
|
||||
mcp: {
|
||||
list: () => import("./commands/handlers/mcp/list"),
|
||||
add: () => import("./commands/handlers/mcp/add"),
|
||||
|
|
|
|||
242
packages/cli/src/ui/timeline.tsx
Normal file
242
packages/cli/src/ui/timeline.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { createCliRenderer, RGBA, type CliRenderer, type ColorInput, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createScrollbackWriter, render, useKeyboard } from "@opentui/solid"
|
||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||
import { Show, createSignal } from "solid-js"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
export type TimelineHost = {
|
||||
readonly signal: AbortSignal
|
||||
intro(text: string): Promise<void>
|
||||
item(text: string): Promise<void>
|
||||
pending(text: string): Promise<void>
|
||||
success(text: string): Promise<void>
|
||||
failure(text: string): Promise<void>
|
||||
outro(text: string): Promise<void>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
type RowKind = "intro" | "item" | "success" | "failure" | "outro"
|
||||
|
||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
const IDLE_TIMEOUT = 1_000
|
||||
const COLORS = {
|
||||
accent: RGBA.fromIndex(6),
|
||||
error: RGBA.fromIndex(1),
|
||||
foreground: RGBA.defaultForeground(),
|
||||
muted: RGBA.fromIndex(8),
|
||||
success: RGBA.fromIndex(2),
|
||||
}
|
||||
const ROWS: Record<RowKind, { marker: string; color: ColorInput; connector: boolean }> = {
|
||||
intro: { marker: "┌", color: COLORS.muted, connector: true },
|
||||
item: { marker: "●", color: COLORS.accent, connector: true },
|
||||
success: { marker: "◇", color: COLORS.success, connector: true },
|
||||
failure: { marker: "■", color: COLORS.error, connector: false },
|
||||
outro: { marker: "└", color: COLORS.muted, connector: false },
|
||||
}
|
||||
|
||||
function row(kind: RowKind, value: string): ScrollbackWriter {
|
||||
const style = ROWS[kind]
|
||||
return createScrollbackWriter(
|
||||
() => (
|
||||
<box width="100%" minHeight={1} flexDirection="column">
|
||||
<box width="100%" minHeight={1} flexDirection="row" gap={1}>
|
||||
<text fg={style.color} flexShrink={0}>
|
||||
{style.marker}
|
||||
</text>
|
||||
<text fg={COLORS.foreground} wrapMode="word">
|
||||
{value}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={style.connector}>
|
||||
<text fg={COLORS.muted}>│</text>
|
||||
</Show>
|
||||
</box>
|
||||
),
|
||||
{ startOnNewLine: true, trailingNewline: !style.connector },
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineFooter(props: { pending: () => string | undefined; cancel: () => void }) {
|
||||
useKeyboard((event) => {
|
||||
if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return
|
||||
event.preventDefault()
|
||||
props.cancel()
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height={1} flexDirection="row" gap={1}>
|
||||
<Show when={props.pending()}>
|
||||
{(text) => (
|
||||
<>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={COLORS.accent} />
|
||||
<text fg={COLORS.foreground} wrapMode="none" truncate>
|
||||
{text()}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function bounded(task: Promise<unknown>) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, IDLE_TIMEOUT)
|
||||
timer.unref()
|
||||
const finish = () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
void task.then(finish, finish)
|
||||
})
|
||||
}
|
||||
|
||||
async function shutdown(renderer: CliRenderer): Promise<void> {
|
||||
await bounded(renderer.idle())
|
||||
try {
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
} finally {
|
||||
try {
|
||||
renderer.screenMode = "main-screen"
|
||||
} finally {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTimelineHost(): Promise<TimelineHost> {
|
||||
const stdout = process.stdout
|
||||
const controller = new AbortController()
|
||||
const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"]
|
||||
const cancel = () => {
|
||||
if (!controller.signal.aborted) controller.abort()
|
||||
}
|
||||
signals.forEach((signal) => process.on(signal, cancel))
|
||||
|
||||
if (!stdout.isTTY || !process.stdin.isTTY) {
|
||||
let closed = false
|
||||
let writing = false
|
||||
let active: Promise<void> | undefined
|
||||
let closeTask: Promise<void> | undefined
|
||||
const write = async (kind: RowKind | "pending", text: string) => {
|
||||
if (closed) throw new Error("timeline closed")
|
||||
if (writing) throw new Error("timeline write already in progress")
|
||||
writing = true
|
||||
try {
|
||||
const style = kind === "pending" ? undefined : ROWS[kind]
|
||||
const marker = kind === "pending" ? "." : ROWS[kind].marker
|
||||
const connector = style?.connector ? "│\n" : ""
|
||||
active = new Promise<void>((resolve, reject) => {
|
||||
stdout.write(`${marker} ${text}\n${connector}`, (error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
await active
|
||||
} finally {
|
||||
writing = false
|
||||
active = undefined
|
||||
}
|
||||
}
|
||||
const close = () => {
|
||||
if (closeTask) return closeTask
|
||||
closed = true
|
||||
closeTask = (async () => {
|
||||
await active?.catch(() => { })
|
||||
signals.forEach((signal) => process.off(signal, cancel))
|
||||
})()
|
||||
return closeTask
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
intro: (text) => write("intro", text),
|
||||
item: (text) => write("item", text),
|
||||
pending: (text) => write("pending", text),
|
||||
success: (text) => write("success", text),
|
||||
failure: (text) => write("failure", text),
|
||||
outro: (text) => write("outro", text),
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: CliRenderer | undefined
|
||||
|
||||
try {
|
||||
// Start on a fresh row so delayed SSH cursor reports cannot make
|
||||
// split-footer overwrite the shell command.
|
||||
process.stdout.write("\n")
|
||||
renderer = await createCliRenderer({
|
||||
stdin: process.stdin,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
exitSignals: [],
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 1,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const activeRenderer = renderer
|
||||
const [pending, setPending] = createSignal<string>()
|
||||
const renderTask = render(() => <TimelineFooter pending={pending} cancel={cancel} />, activeRenderer)
|
||||
void renderTask.catch(cancel)
|
||||
await bounded(activeRenderer.idle())
|
||||
|
||||
let closed = false
|
||||
let writing = false
|
||||
let active: Promise<void> | undefined
|
||||
let closeTask: Promise<void> | undefined
|
||||
const write = (kind: RowKind | "pending", text: string) => {
|
||||
if (closed) return Promise.reject(new Error("timeline closed"))
|
||||
if (writing) return Promise.reject(new Error("timeline write already in progress"))
|
||||
writing = true
|
||||
active = (async () => {
|
||||
if (kind === "pending") {
|
||||
setPending(text)
|
||||
activeRenderer.requestRender()
|
||||
} else {
|
||||
if (kind === "success" || kind === "failure" || kind === "outro") setPending(undefined)
|
||||
activeRenderer.writeToScrollback(row(kind, text))
|
||||
activeRenderer.requestRender()
|
||||
}
|
||||
await bounded(activeRenderer.idle())
|
||||
})().finally(() => {
|
||||
writing = false
|
||||
active = undefined
|
||||
})
|
||||
return active
|
||||
}
|
||||
const close = () => {
|
||||
if (closeTask) return closeTask
|
||||
closed = true
|
||||
closeTask = (async () => {
|
||||
await active?.catch(() => { })
|
||||
try {
|
||||
await shutdown(activeRenderer)
|
||||
await bounded(renderTask)
|
||||
} finally {
|
||||
signals.forEach((signal) => process.off(signal, cancel))
|
||||
}
|
||||
})()
|
||||
return closeTask
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
intro: (text) => write("intro", text),
|
||||
item: (text) => write("item", text),
|
||||
pending: (text) => write("pending", text),
|
||||
success: (text) => write("success", text),
|
||||
failure: (text) => write("failure", text),
|
||||
outro: (text) => write("outro", text),
|
||||
close,
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
if (renderer) await shutdown(renderer)
|
||||
} finally {
|
||||
signals.forEach((signal) => process.off(signal, cancel))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue