Kaynağa Gözat

refactor(cli): effectify provider commands (#25633)

Kit Langton 3 ay önce
ebeveyn
işleme
825ab2e38d

+ 140 - 136
packages/opencode/src/cli/cmd/providers.ts

@@ -1,9 +1,8 @@
 import { Auth } from "../../auth"
-import { AppRuntime } from "../../effect/app-runtime"
 import { cmd } from "./cmd"
-import { effectCmd } from "../effect-cmd"
-import * as prompts from "@clack/prompts"
+import { CliError, effectCmd, fail } from "../effect-cmd"
 import { UI } from "../ui"
+import * as Prompt from "../effect/prompt"
 import { ModelsDev } from "@/provider/models"
 
 import { map, pipe, sortBy, values } from "remeda"
@@ -14,44 +13,57 @@ import { Global } from "@opencode-ai/core/global"
 import { Plugin } from "../../plugin"
 import type { Hooks } from "@opencode-ai/plugin"
 import { Process } from "@/util/process"
+import { errorMessage } from "@/util/error"
 import { text } from "node:stream/consumers"
-import { Effect } from "effect"
+import { Effect, Option } from "effect"
 
 type PluginAuth = NonNullable<Hooks["auth"]>
 
-const put = (key: string, info: Auth.Info) =>
-  AppRuntime.runPromise(
-    Effect.gen(function* () {
-      const auth = yield* Auth.Service
-      yield* auth.set(key, info)
-    }),
-  )
-
-async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
-  let index = 0
-  if (methodName) {
+const promptValue = <Value>(value: Option.Option<Value>) => {
+  if (Option.isNone(value)) return Effect.die(new UI.CancelledError())
+  return Effect.succeed(value.value)
+}
+
+const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) {
+  const auth = yield* Auth.Service
+  yield* Effect.orDie(auth.set(key, info))
+})
+
+const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) =>
+  Effect.tryPromise({
+    try: fn,
+    catch: (error) => new CliError({ message: message + errorMessage(error) }),
+  })
+
+const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
+  plugin: { auth: PluginAuth },
+  provider: string,
+  methodName?: string,
+) {
+  const index = yield* Effect.gen(function* () {
+    if (!methodName) {
+      if (plugin.auth.methods.length <= 1) return 0
+      return yield* promptValue(
+        yield* Prompt.select({
+          message: "Login method",
+          options: plugin.auth.methods.map((x, index) => ({
+            label: x.label,
+            value: index,
+          })),
+        }),
+      )
+    }
     const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
     if (match === -1) {
-      prompts.log.error(
+      return yield* fail(
         `Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
       )
-      process.exit(1)
     }
-    index = match
-  } else if (plugin.auth.methods.length > 1) {
-    const method = await prompts.select({
-      message: "Login method",
-      options: plugin.auth.methods.map((x, index) => ({
-        label: x.label,
-        value: index.toString(),
-      })),
-    })
-    if (prompts.isCancel(method)) throw new UI.CancelledError()
-    index = parseInt(method)
-  }
+    return match
+  })
   const method = plugin.auth.methods[index]
 
-  await new Promise((r) => setTimeout(r, 10))
+  yield* Effect.sleep("10 millis")
   const inputs: Record<string, string> = {}
   if (method.prompts) {
     for (const prompt of method.prompts) {
@@ -63,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
       }
       if (prompt.condition && !prompt.condition(inputs)) continue
       if (prompt.type === "select") {
-        const value = await prompts.select({
+        const value = yield* Prompt.select({
           message: prompt.message,
           options: prompt.options,
         })
-        if (prompts.isCancel(value)) throw new UI.CancelledError()
-        inputs[prompt.key] = value
-      } else {
-        const value = await prompts.text({
-          message: prompt.message,
-          placeholder: prompt.placeholder,
-          validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
-        })
-        if (prompts.isCancel(value)) throw new UI.CancelledError()
-        inputs[prompt.key] = value
+        inputs[prompt.key] = yield* promptValue(value)
+        continue
       }
+      const value = yield* Prompt.text({
+        message: prompt.message,
+        placeholder: prompt.placeholder,
+        validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
+      })
+      inputs[prompt.key] = yield* promptValue(value)
     }
   }
 
   if (method.type === "oauth") {
-    const authorize = await method.authorize(inputs)
+    const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs))
 
     if (authorize.url) {
-      prompts.log.info("Go to: " + authorize.url)
+      yield* Prompt.log.info("Go to: " + authorize.url)
     }
 
     if (authorize.method === "auto") {
       if (authorize.instructions) {
-        prompts.log.info(authorize.instructions)
+        yield* Prompt.log.info(authorize.instructions)
       }
-      const spinner = prompts.spinner()
-      spinner.start("Waiting for authorization...")
-      const result = await authorize.callback()
+      const spinner = Prompt.spinner()
+      yield* spinner.start("Waiting for authorization...")
+      const result = yield* cliTry("Failed to authorize: ", () => authorize.callback())
       if (result.type === "failed") {
-        spinner.stop("Failed to authorize", 1)
+        yield* spinner.stop("Failed to authorize", 1)
       }
       if (result.type === "success") {
         const saveProvider = result.provider ?? provider
         if ("refresh" in result) {
           const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
-          await put(saveProvider, {
+          yield* put(saveProvider, {
             type: "oauth",
             refresh,
             access,
@@ -111,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
           })
         }
         if ("key" in result) {
-          await put(saveProvider, {
+          yield* put(saveProvider, {
             type: "api",
             key: result.key,
           })
         }
-        spinner.stop("Login successful")
+        yield* spinner.stop("Login successful")
       }
     }
 
     if (authorize.method === "code") {
-      const code = await prompts.text({
+      const code = yield* Prompt.text({
         message: "Paste the authorization code here: ",
         validate: (x) => (x && x.length > 0 ? undefined : "Required"),
       })
-      if (prompts.isCancel(code)) throw new UI.CancelledError()
-      const result = await authorize.callback(code)
+      const authorizationCode = yield* promptValue(code)
+      const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode))
       if (result.type === "failed") {
-        prompts.log.error("Failed to authorize")
+        yield* Prompt.log.error("Failed to authorize")
       }
       if (result.type === "success") {
         const saveProvider = result.provider ?? provider
         if ("refresh" in result) {
           const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
-          await put(saveProvider, {
+          yield* put(saveProvider, {
             type: "oauth",
             refresh,
             access,
@@ -143,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
           })
         }
         if ("key" in result) {
-          await put(saveProvider, {
+          yield* put(saveProvider, {
             type: "api",
             key: result.key,
           })
         }
-        prompts.log.success("Login successful")
+        yield* Prompt.log.success("Login successful")
       }
     }
 
-    prompts.outro("Done")
+    yield* Prompt.outro("Done")
     return true
   }
 
   if (method.type === "api") {
-    const key = await prompts.password({
+    const key = yield* Prompt.password({
       message: "Enter your API key",
       validate: (x) => (x && x.length > 0 ? undefined : "Required"),
     })
-    if (prompts.isCancel(key)) throw new UI.CancelledError()
+    const apiKey = yield* promptValue(key)
 
     const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
-    if (!method.authorize) {
-      await put(provider, {
+    const authorizeApi = method.authorize
+    if (!authorizeApi) {
+      yield* put(provider, {
         type: "api",
-        key,
+        key: apiKey,
         ...metadata,
       })
-      prompts.outro("Done")
+      yield* Prompt.outro("Done")
       return true
     }
 
-    const result = await method.authorize(inputs)
+    const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs))
     if (result.type === "failed") {
-      prompts.log.error("Failed to authorize")
+      yield* Prompt.log.error("Failed to authorize")
     }
     if (result.type === "success") {
       const saveProvider = result.provider ?? provider
-      await put(saveProvider, {
+      yield* put(saveProvider, {
         type: "api",
-        key: result.key ?? key,
+        key: result.key ?? apiKey,
         ...metadata,
       })
-      prompts.log.success("Login successful")
+      yield* Prompt.log.success("Login successful")
     }
-    prompts.outro("Done")
+    yield* Prompt.outro("Done")
     return true
   }
 
   return false
-}
+})
 
 export function resolvePluginProviders(input: {
   hooks: Hooks[]
@@ -244,16 +255,16 @@ export const ProvidersListCommand = effectCmd({
     const authPath = path.join(Global.Path.data, "auth.json")
     const homedir = os.homedir()
     const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
-    prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
+    yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
     const results = Object.entries(yield* Effect.orDie(authSvc.all()))
     const database = yield* modelsDev.get()
 
     for (const [providerID, result] of results) {
       const name = database[providerID]?.name || providerID
-      prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
+      yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
     }
 
-    prompts.outro(`${results.length} credentials`)
+    yield* Prompt.outro(`${results.length} credentials`)
 
     const activeEnvVars: Array<{ provider: string; envVar: string }> = []
 
@@ -270,13 +281,13 @@ export const ProvidersListCommand = effectCmd({
 
     if (activeEnvVars.length > 0) {
       UI.empty()
-      prompts.intro("Environment")
+      yield* Prompt.intro("Environment")
 
       for (const { provider, envVar } of activeEnvVars) {
-        prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
+        yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
       }
 
-      prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
+      yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
     }
   }),
 })
@@ -301,36 +312,42 @@ export const ProvidersLoginCommand = effectCmd({
         type: "string",
       }),
   handler: Effect.fn("Cli.providers.login")(function* (args) {
-    const cfgSvc = yield* Config.Service
-    const pluginSvc = yield* Plugin.Service
-    const modelsDev = yield* ModelsDev.Service
     const authSvc = yield* Auth.Service
 
     UI.empty()
-    prompts.intro("Add credential")
+    yield* Prompt.intro("Add credential")
     if (args.url) {
       const url = args.url.replace(/\/+$/, "")
-      const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as {
+      const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
+        fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
+      )) as {
         auth: { command: string[]; env: string }
       }
-      prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
-      const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" })
+      yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
+      const abort = new AbortController()
+      const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
       if (!proc.stdout) {
-        prompts.log.error("Failed")
-        prompts.outro("Done")
+        yield* Prompt.log.error("Failed")
+        yield* Prompt.outro("Done")
         return
       }
-      const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)]))
+      const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
+        Promise.all([proc.exited, text(proc.stdout!)]),
+      ).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
       if (exit !== 0) {
-        prompts.log.error("Failed")
-        prompts.outro("Done")
+        yield* Prompt.log.error("Failed")
+        yield* Prompt.outro("Done")
         return
       }
       yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
-      prompts.log.success("Logged into " + url)
-      prompts.outro("Done")
+      yield* Prompt.log.success("Logged into " + url)
+      yield* Prompt.outro("Done")
       return
     }
+
+    const cfgSvc = yield* Config.Service
+    const pluginSvc = yield* Plugin.Service
+    const modelsDev = yield* ModelsDev.Service
     yield* Effect.ignore(modelsDev.refresh(true))
 
     const config = yield* cfgSvc.get()
@@ -392,53 +409,46 @@ export const ProvidersLoginCommand = effectCmd({
       const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
       const match = byID ?? byName
       if (!match) {
-        prompts.log.error(`Unknown provider "${input}"`)
-        process.exit(1)
+        return yield* fail(`Unknown provider "${input}"`)
       }
       provider = match.value
     } else {
-      const selected = yield* Effect.promise(() =>
-        prompts.autocomplete({
+      provider = yield* promptValue(
+        yield* Prompt.autocomplete({
           message: "Select provider",
           maxItems: 8,
           options: [...options, { value: "other", label: "Other" }],
         }),
       )
-      if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
-      provider = selected as string
     }
 
     const plugin = hooks.findLast((x) => x.auth?.provider === provider)
     if (plugin && plugin.auth) {
-      const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method))
+      const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method)
       if (handled) return
     }
 
     if (provider === "other") {
-      const custom = yield* Effect.promise(() =>
-        prompts.text({
+      provider = (yield* promptValue(
+        yield* Prompt.text({
           message: "Enter provider id",
           validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
         }),
-      )
-      if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError())
-      provider = (custom as string).replace(/^@ai-sdk\//, "")
+      )).replace(/^@ai-sdk\//, "")
 
       const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
       if (customPlugin && customPlugin.auth) {
-        const handled = yield* Effect.promise(() =>
-          handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method),
-        )
+        const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method)
         if (handled) return
       }
 
-      prompts.log.warn(
+      yield* Prompt.log.warn(
         `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
       )
     }
 
     if (provider === "amazon-bedrock") {
-      prompts.log.info(
+      yield* Prompt.log.info(
         "Amazon Bedrock authentication priority:\n" +
           "  1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
           "  2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
@@ -448,29 +458,27 @@ export const ProvidersLoginCommand = effectCmd({
     }
 
     if (provider === "opencode") {
-      prompts.log.info("Create an api key at https://opencode.ai/auth")
+      yield* Prompt.log.info("Create an api key at https://opencode.ai/auth")
     }
 
     if (provider === "vercel") {
-      prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
+      yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
     }
 
     if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
-      prompts.log.info(
+      yield* Prompt.log.info(
         "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
       )
     }
 
-    const key = yield* Effect.promise(() =>
-      prompts.password({
-        message: "Enter your API key",
-        validate: (x) => (x && x.length > 0 ? undefined : "Required"),
-      }),
-    )
-    if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError())
-    yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string }))
+    const key = yield* Prompt.password({
+      message: "Enter your API key",
+      validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+    })
+    const apiKey = yield* promptValue(key)
+    yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }))
 
-    prompts.outro("Done")
+    yield* Prompt.outro("Done")
   }),
 })
 
@@ -485,24 +493,20 @@ export const ProvidersLogoutCommand = effectCmd({
 
     UI.empty()
     const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
-    prompts.intro("Remove credential")
+    yield* Prompt.intro("Remove credential")
     if (credentials.length === 0) {
-      prompts.log.error("No credentials found")
+      yield* Prompt.log.error("No credentials found")
       return
     }
     const database = yield* modelsDev.get()
-    const selected = yield* Effect.promise(() =>
-      prompts.select({
-        message: "Select provider",
-        options: credentials.map(([key, value]) => ({
-          label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
-          value: key,
-        })),
-      }),
-    )
-    if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError())
-    const providerID = selected as string
-    yield* Effect.orDie(authSvc.remove(providerID))
-    prompts.outro("Logout successful")
+    const selected = yield* Prompt.select({
+      message: "Select provider",
+      options: credentials.map(([key, value]) => ({
+        label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
+        value: key,
+      })),
+    })
+    yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
+    yield* Prompt.outro("Logout successful")
   }),
 })

+ 18 - 6
packages/opencode/src/cli/effect/prompt.ts

@@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
 
 export const log = {
   info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
+  error: (msg: string) => Effect.sync(() => prompts.log.error(msg)),
+  warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)),
+  success: (msg: string) => Effect.sync(() => prompts.log.success(msg)),
+}
+
+const optional = <Value>(result: Value | symbol) => {
+  if (prompts.isCancel(result)) return Option.none<Value>()
+  return Option.some(result)
 }
 
 export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
-  Effect.tryPromise(() => prompts.select(opts)).pipe(
-    Effect.map((result) => {
-      if (prompts.isCancel(result)) return Option.none<Value>()
-      return Option.some(result)
-    }),
-  )
+  Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result)))
+
+export const autocomplete = <Value>(opts: Parameters<typeof prompts.autocomplete<Value>>[0]) =>
+  Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result)))
+
+export const text = (opts: Parameters<typeof prompts.text>[0]) =>
+  Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result)))
+
+export const password = (opts: Parameters<typeof prompts.password>[0]) =>
+  Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result)))
 
 export const spinner = () => {
   const s = prompts.spinner()