Преглед изворни кода

Refactor authentication system to consolidate auth flow and remove provider-based commands

🤖 Generated with [OpenCode](https://opencode.ai)

Co-Authored-By: OpenCode <[email protected]>
Dax Raad пре 8 месеци
родитељ
комит
83eb61fd5f

+ 0 - 1
packages/opencode/src/app/app.ts

@@ -35,7 +35,6 @@ export namespace App {
   async function create(input: { cwd: string; version: string }) {
     log.info("creating", {
       cwd: input.cwd,
-      version: input.version,
     })
     const git = await Filesystem.findUp(".git", input.cwd).then(([x]) =>
       x ? path.dirname(x) : undefined,

+ 15 - 13
packages/opencode/src/auth/anthropic.ts

@@ -1,13 +1,10 @@
 import { generatePKCE } from "@openauthjs/openauth/pkce"
-import { Global } from "../global"
-import path from "path"
 import fs from "fs/promises"
+import { Auth } from "./index"
 
 export namespace AuthAnthropic {
   const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
 
-  const filepath = path.join(Global.Path.data, "auth", "anthropic.json")
-
   export async function authorize() {
     const pkce = await generatePKCE()
     const url = new URL("https://claude.ai/oauth/authorize", import.meta.url)
@@ -48,16 +45,17 @@ export namespace AuthAnthropic {
       }),
     })
     if (!result.ok) throw new ExchangeFailed()
-    const file = Bun.file(filepath)
-    await Bun.write(file, result)
-    await fs.chmod(file.name!, 0o600)
+    const json = await result.json()
+    await Auth.set("anthropic", {
+      type: "oauth",
+      refresh: json.refresh_token as string,
+      expires: Date.now() + json.expires_in * 1000,
+    })
   }
 
   export async function access() {
-    const file = Bun.file(filepath)
-    const result = await file.json().catch(() => ({}))
-    if (!result) return
-    const refresh = result.refresh_token
+    const info = await Auth.get("anthropic")
+    if (!info || info.type !== "oauth") return
     const response = await fetch(
       "https://console.anthropic.com/v1/oauth/token",
       {
@@ -67,14 +65,18 @@ export namespace AuthAnthropic {
         },
         body: JSON.stringify({
           grant_type: "refresh_token",
-          refresh_token: refresh,
+          refresh_token: info.refresh,
           client_id: CLIENT_ID,
         }),
       },
     )
     if (!response.ok) return
     const json = await response.json()
-    await Bun.write(file, JSON.stringify(json))
+    await Auth.set("anthropic", {
+      type: "oauth",
+      refresh: json.refresh_token as string,
+      expires: Date.now() + json.expires_in * 1000,
+    })
     return json.access_token as string
   }
 

+ 50 - 0
packages/opencode/src/auth/index.ts

@@ -0,0 +1,50 @@
+import path from "path"
+import { Global } from "../global"
+import fs from "fs/promises"
+import { z } from "zod"
+
+export namespace Auth {
+  export const Oauth = z.object({
+    type: z.literal("oauth"),
+    refresh: z.string(),
+    expires: z.number(),
+  })
+
+  export const Api = z.object({
+    type: z.literal("api"),
+    key: z.string(),
+  })
+
+  export const Info = z.discriminatedUnion("type", [Oauth, Api])
+  export type Info = z.infer<typeof Info>
+
+  const filepath = path.join(Global.Path.data, "auth.json")
+
+  export async function get(providerID: string) {
+    const file = Bun.file(filepath)
+    return file
+      .json()
+      .catch(() => ({}))
+      .then((x) => x[providerID] as Info | undefined)
+  }
+
+  export async function all(): Promise<Record<string, Info>> {
+    const file = Bun.file(filepath)
+    return file.json().catch(() => ({}))
+  }
+
+  export async function set(key: string, info: Info) {
+    const file = Bun.file(filepath)
+    const data = await all()
+    await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2))
+    await fs.chmod(file.name!, 0o600)
+  }
+
+  export async function remove(key: string) {
+    const file = Bun.file(filepath)
+    const data = await all()
+    delete data[key]
+    await Bun.write(file, JSON.stringify(data, null, 2))
+    await fs.chmod(file.name!, 0o600)
+  }
+}

+ 0 - 22
packages/opencode/src/auth/keys.ts

@@ -1,22 +0,0 @@
-import path from "path"
-import { Global } from "../global"
-import fs from "fs/promises"
-
-export namespace AuthKeys {
-  const filepath = path.join(Global.Path.data, "auth", "keys.json")
-
-  export async function get() {
-    const file = Bun.file(filepath)
-    return file
-      .json()
-      .catch(() => ({}))
-      .then((x) => x as Record<string, string>)
-  }
-
-  export async function set(key: string, value: string) {
-    const file = Bun.file(filepath)
-    const env = await get()
-    await Bun.write(file, JSON.stringify({ ...env, [key]: value }))
-    await fs.chmod(file.name!, 0o600)
-  }
-}

+ 144 - 0
packages/opencode/src/cli/cmd/auth.ts

@@ -0,0 +1,144 @@
+import { AuthAnthropic } from "../../auth/anthropic"
+import { Auth } from "../../auth"
+import { cmd } from "./cmd"
+import * as prompts from "@clack/prompts"
+import open from "open"
+import { UI } from "../ui"
+import { ModelsDev } from "../../provider/models"
+
+export const AuthCommand = cmd({
+  command: "auth",
+  builder: (yargs) =>
+    yargs
+      .command(AuthLoginCommand)
+      .command(AuthLogoutCommand)
+      .command(AuthListCommand)
+      .demandCommand(),
+  async handler(args) {},
+})
+
+export const AuthListCommand = cmd({
+  command: "list",
+  aliases: ["ls"],
+  describe: "list providers",
+  async handler() {
+    UI.empty()
+    prompts.intro("Credentials")
+    const results = await Auth.all().then((x) => Object.entries(x))
+    const database = await ModelsDev.get()
+
+    for (const [providerID, result] of results) {
+      const name = database[providerID]?.name || providerID
+      prompts.log.info(`${name} ${Bun.color("gray", "ansi")}(${result.type})`)
+    }
+
+    prompts.outro(`${results.length} credentials`)
+  },
+})
+
+export const AuthLoginCommand = cmd({
+  command: "login",
+  describe: "login to a provider",
+  async handler() {
+    UI.empty()
+    prompts.intro("Add credential")
+    const provider = await prompts.select({
+      message: "Select provider",
+      maxItems: 2,
+      options: [
+        {
+          label: "Anthropic",
+          value: "anthropic",
+        },
+        {
+          label: "OpenAI",
+          value: "openai",
+        },
+        {
+          label: "Google",
+          value: "google",
+        },
+      ],
+    })
+    if (prompts.isCancel(provider)) throw new UI.CancelledError()
+
+    if (provider === "anthropic") {
+      const method = await prompts.select({
+        message: "Login method",
+        options: [
+          {
+            label: "Claude Pro/Max",
+            value: "oauth",
+          },
+          {
+            label: "API Key",
+            value: "api",
+          },
+        ],
+      })
+      if (prompts.isCancel(method)) throw new UI.CancelledError()
+
+      if (method === "oauth") {
+        // some weird bug where program exits without this
+        await new Promise((resolve) => setTimeout(resolve, 10))
+        const { url, verifier } = await AuthAnthropic.authorize()
+        prompts.note("Opening browser...")
+        await open(url)
+        prompts.log.info(url)
+
+        const code = await prompts.text({
+          message: "Paste the authorization code here: ",
+          validate: (x) => (x.length > 0 ? undefined : "Required"),
+        })
+        if (prompts.isCancel(code)) throw new UI.CancelledError()
+
+        await AuthAnthropic.exchange(code, verifier)
+          .then(() => {
+            prompts.log.success("Login successful")
+          })
+          .catch(() => {
+            prompts.log.error("Invalid code")
+          })
+        prompts.outro("Done")
+        return
+      }
+    }
+
+    const key = await prompts.password({
+      message: "Enter your API key",
+      validate: (x) => (x.length > 0 ? undefined : "Required"),
+    })
+    if (prompts.isCancel(key)) throw new UI.CancelledError()
+    await Auth.set(provider, {
+      type: "api",
+      key,
+    })
+
+    prompts.outro("Done")
+  },
+})
+
+export const AuthLogoutCommand = cmd({
+  command: "logout",
+  describe: "logout from a configured provider",
+  async handler() {
+    UI.empty()
+    const credentials = await Auth.all().then((x) => Object.entries(x))
+    prompts.intro("Remove credential")
+    if (credentials.length === 0) {
+      prompts.log.error("No credentials found")
+      return
+    }
+    const database = await ModelsDev.get()
+    const providerID = await prompts.select({
+      message: "Select credential",
+      options: credentials.map(([key, value]) => ({
+        label: database[key]?.name || key,
+        value: key,
+      })),
+    })
+    if (prompts.isCancel(providerID)) throw new UI.CancelledError()
+    await Auth.remove(providerID)
+    prompts.outro("Logout successful")
+  },
+})

+ 0 - 120
packages/opencode/src/cli/cmd/provider.ts

@@ -1,120 +0,0 @@
-import { App } from "../../app/app"
-import { AuthAnthropic } from "../../auth/anthropic"
-import { AuthKeys } from "../../auth/keys"
-import { cmd } from "./cmd"
-import * as prompts from "@clack/prompts"
-import open from "open"
-import { VERSION } from "../version"
-import { Provider } from "../../provider/provider"
-import { UI } from "../ui"
-
-export const ProviderCommand = cmd({
-  command: "provider",
-  builder: (yargs) =>
-    yargs
-      .command(ProviderAddCommand)
-      .command(ProviderListCommand)
-      .demandCommand(),
-  describe: "initialize opencode",
-  async handler() {},
-})
-
-export const ProviderListCommand = cmd({
-  command: "list",
-  aliases: ["ls"],
-  describe: "list providers",
-  async handler() {
-    await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
-      prompts.intro("Providers")
-      const providers = await Provider.list().then((x) => Object.values(x))
-      for (const value of providers) {
-        prompts.log.success(value.info.name + " (" + value.source + ")")
-      }
-      prompts.outro(`${providers.length} configured`)
-    })
-  },
-})
-
-export const ProviderAddCommand = cmd({
-  command: "add",
-  describe: "add credentials for various providers",
-  async handler() {
-    await App.provide({ cwd: process.cwd(), version: VERSION }, async () => {
-      const providers = await Provider.list()
-      prompts.intro("Add provider")
-      const provider = await prompts.select({
-        message: "Select",
-        maxItems: 2,
-        options: [
-          {
-            label: "Anthropic",
-            value: "anthropic",
-            hint: providers["anthropic"] ? "configured" : "",
-          },
-          {
-            label: "OpenAI",
-            value: "openai",
-            hint: providers["openai"] ? "configured" : "",
-          },
-          {
-            label: "Google",
-            value: "google",
-            hint: providers["google"] ? "configured" : "",
-          },
-        ],
-      })
-      if (prompts.isCancel(provider)) throw new UI.CancelledError({})
-
-      if (provider === "anthropic") {
-        const method = await prompts.select({
-          message: "Login method",
-          options: [
-            {
-              label: "Claude Pro/Max",
-              value: "oauth",
-            },
-            {
-              label: "API Key",
-              value: "api",
-            },
-          ],
-        })
-        if (prompts.isCancel(method)) throw new UI.CancelledError({})
-
-        if (method === "oauth") {
-          // some weird bug where program exits without this
-          await new Promise((resolve) => setTimeout(resolve, 10))
-          const { url, verifier } = await AuthAnthropic.authorize()
-          prompts.note("Opening browser...")
-          await open(url)
-          prompts.log.info(url)
-
-          const code = await prompts.text({
-            message: "Paste the authorization code here: ",
-            validate: (x) => (x.length > 0 ? undefined : "Required"),
-          })
-          if (prompts.isCancel(code)) throw new UI.CancelledError({})
-
-          await AuthAnthropic.exchange(code, verifier)
-            .then(() => {
-              prompts.log.success("Login successful")
-            })
-            .catch(() => {
-              prompts.log.error("Invalid code")
-            })
-          prompts.outro("Done")
-          return
-        }
-      }
-
-      const key = await prompts.password({
-        message: "Enter your API key",
-        validate: (x) => (x.length > 0 ? undefined : "Required"),
-      })
-      if (prompts.isCancel(key)) throw new UI.CancelledError({})
-      await AuthKeys.set(provider, key)
-
-      prompts.outro("Done")
-    })
-  },
-})

+ 2 - 2
packages/opencode/src/cli/cmd/run.ts

@@ -33,7 +33,7 @@ export const RunCommand = {
     await App.provide(
       {
         cwd: process.cwd(),
-        version: "0.0.0",
+        version: VERSION,
       },
       async () => {
         await Share.init()
@@ -47,7 +47,7 @@ export const RunCommand = {
         UI.empty()
         UI.println(
           UI.Style.TEXT_INFO_BOLD +
-            "~  https://dev.opencode.ai/s?id=" +
+            "~  https://dev.opencode.ai/s/" +
             session.id.slice(-8),
         )
         UI.empty()

+ 1 - 4
packages/opencode/src/cli/ui.ts

@@ -8,10 +8,7 @@ export namespace UI {
     `▀▀▀▀ █▀▀▀ ▀▀▀ ▀  ▀ ▀▀▀ ▀▀▀▀ ▀▀▀  ▀▀▀`,
   ]
 
-  export const CancelledError = NamedError.create(
-    "UICancelledError",
-    z.object({}),
-  )
+  export const CancelledError = NamedError.create("UICancelledError", z.void())
 
   export const Style = {
     TEXT_HIGHLIGHT: "\x1b[96m",

+ 86 - 67
packages/opencode/src/index.ts

@@ -15,82 +15,101 @@ import { GenerateCommand } from "./cli/cmd/generate"
 import { VERSION } from "./cli/version"
 import { ScrapCommand } from "./cli/cmd/scrap"
 import { Log } from "./util/log"
-import { ProviderAddCommand, ProviderCommand } from "./cli/cmd/provider"
+import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth"
 import { Provider } from "./provider/provider"
 import { UI } from "./cli/ui"
 
-await Log.init({ print: process.argv.includes("--print-logs") })
-
-try {
-  await yargs(hideBin(process.argv))
-    .scriptName("opencode")
-    .version(VERSION)
-    .command({
-      command: "$0",
-      describe: "Start OpenCode TUI",
-      handler: async (args) => {
-        while (true) {
-          const result = await App.provide(
-            { cwd: process.cwd(), version: VERSION },
-            async () => {
-              const providers = await Provider.list()
-              if (Object.keys(providers).length === 0) {
-                return "needs_provider"
-              }
+const cli = yargs(hideBin(process.argv))
+  .scriptName("opencode")
+  .version(VERSION)
+  .option("print-logs", {
+    describe: "Print logs to stderr",
+    type: "boolean",
+  })
+  .middleware(async (args) => {
+    await Log.init({ print: process.argv.includes("--print-logs") })
+    Log.Default.info("opencode", {
+      version: VERSION,
+      args: process.argv.slice(2),
+    })
+  })
+  .command({
+    command: "$0",
+    describe: "Start OpenCode TUI",
+    handler: async (args) => {
+      while (true) {
+        const result = await App.provide(
+          { cwd: process.cwd(), version: VERSION },
+          async () => {
+            const providers = await Provider.list()
+            if (Object.keys(providers).length === 0) {
+              return "needs_provider"
+            }
 
-              await Share.init()
-              const server = Server.listen()
+            await Share.init()
+            const server = Server.listen()
 
-              let cmd = ["go", "run", "./main.go"]
-              let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
-                .pathname
-              if (Bun.embeddedFiles.length > 0) {
-                const blob = Bun.embeddedFiles[0] as File
-                const binary = path.join(Global.Path.cache, "tui", blob.name)
-                const file = Bun.file(binary)
-                if (!(await file.exists())) {
-                  await Bun.write(file, blob, { mode: 0o755 })
-                  await fs.chmod(binary, 0o755)
-                }
-                cwd = process.cwd()
-                cmd = [binary]
+            let cmd = ["go", "run", "./main.go"]
+            let cwd = new URL("../../tui/cmd/opencode", import.meta.url)
+              .pathname
+            if (Bun.embeddedFiles.length > 0) {
+              const blob = Bun.embeddedFiles[0] as File
+              const binary = path.join(Global.Path.cache, "tui", blob.name)
+              const file = Bun.file(binary)
+              if (!(await file.exists())) {
+                await Bun.write(file, blob, { mode: 0o755 })
+                await fs.chmod(binary, 0o755)
               }
-              const proc = Bun.spawn({
-                cmd,
-                cwd,
-                stdout: "inherit",
-                stderr: "inherit",
-                stdin: "inherit",
-                env: {
-                  ...process.env,
-                  OPENCODE_SERVER: server.url.toString(),
-                },
-                onExit: () => {
-                  server.stop()
-                },
-              })
-              await proc.exited
-              await server.stop()
+              cwd = process.cwd()
+              cmd = [binary]
+            }
+            const proc = Bun.spawn({
+              cmd,
+              cwd,
+              stdout: "inherit",
+              stderr: "inherit",
+              stdin: "inherit",
+              env: {
+                ...process.env,
+                OPENCODE_SERVER: server.url.toString(),
+              },
+              onExit: () => {
+                server.stop()
+              },
+            })
+            await proc.exited
+            await server.stop()
 
-              return "done"
-            },
-          )
-          if (result === "done") break
-          if (result === "needs_provider") {
-            UI.logo()
-            await ProviderAddCommand.handler(args)
-          }
+            return "done"
+          },
+        )
+        if (result === "done") break
+        if (result === "needs_provider") {
+          UI.logo()
+          await AuthLoginCommand.handler(args)
         }
-      },
+      }
+    },
+  })
+  .command(RunCommand)
+  .command(GenerateCommand)
+  .command(ScrapCommand)
+  .command(AuthCommand)
+  .fail((msg, err) => {
+    if (
+      msg.startsWith("Unknown argument") ||
+      msg.startsWith("Not enough non-option arguments")
+    ) {
+      cli.showHelp("log")
+    }
+    Log.Default.error(msg, {
+      err,
     })
-    .command(RunCommand)
-    .command(GenerateCommand)
-    .command(ScrapCommand)
-    .command(ProviderCommand)
-    .fail((msg, err) => {
-      Log.Default.error(msg)
-    })
-    .parse()
+  })
+  .strict()
+
+try {
+  await cli.parse()
 } catch (e) {
   Log.Default.error(e)
 }

+ 6 - 4
packages/opencode/src/provider/provider.ts

@@ -23,7 +23,7 @@ import { TodoReadTool, TodoWriteTool } from "../tool/todo"
 import { AuthAnthropic } from "../auth/anthropic"
 import { ModelsDev } from "./models"
 import { NamedError } from "../util/error"
-import { AuthKeys } from "../auth/keys"
+import { Auth } from "../auth"
 
 export namespace Provider {
   const log = Log.create({ service: "provider" })
@@ -84,7 +84,7 @@ export namespace Provider {
     return result
   }
 
-  type Source = "oauth" | "env" | "config" | "global"
+  type Source = "oauth" | "env" | "config" | "api"
 
   const AUTODETECT: Record<string, Autodetector> = {
     async anthropic(provider) {
@@ -162,8 +162,10 @@ export namespace Provider {
       mergeProvider(providerID, result.options, result.source)
     }
 
-    for (const [providerID, key] of Object.entries(await AuthKeys.get())) {
-      mergeProvider(providerID, { apiKey: key }, "global")
+    for (const [providerID, info] of Object.entries(await Auth.all())) {
+      if (info.type === "api") {
+        mergeProvider(providerID, { apiKey: info.key }, "api")
+      }
     }
 
     for (const [providerID, options] of Object.entries(config.provider ?? {})) {

+ 4 - 1
packages/opencode/src/util/error.ts

@@ -21,6 +21,8 @@ export abstract class NamedError extends Error {
           ref: name,
         })
 
+      public readonly name = name as Name
+
       constructor(
         public readonly data: z.input<Data>,
         options?: ErrorOptions,
@@ -35,7 +37,7 @@ export abstract class NamedError extends Error {
       }
 
       schema() {
-        return data
+        return result.Schema
       }
 
       toObject() {
@@ -45,6 +47,7 @@ export abstract class NamedError extends Error {
         }
       }
     }
+    Object.defineProperty(result, "name", { value: name })
     return result
   }
 

+ 0 - 1
packages/opencode/src/util/log.ts

@@ -28,7 +28,6 @@ export namespace Log {
       writer.flush()
       return true
     }
-    Default.info("initialized", { file: logpath })
   }
 
   async function cleanup(dir: string) {