From d16e5b98dcc9bb443ee8e56cdc96dd7b53bbc06a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 13:54:04 -0500 Subject: [PATCH] core: rename control module to account for clearer authentication management Refactor internal authentication system by renaming the control module to account, making it easier to understand that this handles user account credentials and tokens. Simplify database schema management by removing the centralized schema exports and letting each module manage its own tables directly. --- .../migration.sql | 1 + .../snapshot.json | 1011 +++++++++++++++++ .../control.sql.ts => account/account.sql.ts} | 7 +- .../src/{control => account}/index.ts | 18 +- packages/opencode/src/cli/cmd/auth.ts | 449 ++++++++ packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/storage/db.ts | 9 +- packages/opencode/src/storage/schema.ts | 5 - 8 files changed, 1476 insertions(+), 28 deletions(-) create mode 100644 packages/opencode/migration/20260228185036_shocking_namor/migration.sql create mode 100644 packages/opencode/migration/20260228185036_shocking_namor/snapshot.json rename packages/opencode/src/{control/control.sql.ts => account/account.sql.ts} (71%) rename packages/opencode/src/{control => account}/index.ts (70%) create mode 100644 packages/opencode/src/cli/cmd/auth.ts delete mode 100644 packages/opencode/src/storage/schema.ts diff --git a/packages/opencode/migration/20260228185036_shocking_namor/migration.sql b/packages/opencode/migration/20260228185036_shocking_namor/migration.sql new file mode 100644 index 0000000000..9a259f5bd9 --- /dev/null +++ b/packages/opencode/migration/20260228185036_shocking_namor/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `control_account` RENAME TO `account`; \ No newline at end of file diff --git a/packages/opencode/migration/20260228185036_shocking_namor/snapshot.json b/packages/opencode/migration/20260228185036_shocking_namor/snapshot.json new file mode 100644 index 0000000000..625fd35430 --- /dev/null +++ b/packages/opencode/migration/20260228185036_shocking_namor/snapshot.json @@ -0,0 +1,1011 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "3200a36f-5de6-4b78-9f8c-b8553fbe64f6", + "prevIds": [ + "1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [ + "control_account->account" + ] +} \ No newline at end of file diff --git a/packages/opencode/src/control/control.sql.ts b/packages/opencode/src/account/account.sql.ts similarity index 71% rename from packages/opencode/src/control/control.sql.ts rename to packages/opencode/src/account/account.sql.ts index 7b805c1627..84c383401f 100644 --- a/packages/opencode/src/control/control.sql.ts +++ b/packages/opencode/src/account/account.sql.ts @@ -1,9 +1,8 @@ -import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core" -import { eq } from "drizzle-orm" +import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" import { Timestamps } from "@/storage/schema.sql" -export const ControlAccountTable = sqliteTable( - "control_account", +export const AccountTable = sqliteTable( + "account", { email: text().notNull(), url: text().notNull(), diff --git a/packages/opencode/src/control/index.ts b/packages/opencode/src/account/index.ts similarity index 70% rename from packages/opencode/src/control/index.ts rename to packages/opencode/src/account/index.ts index f712e88281..c59eef54fd 100644 --- a/packages/opencode/src/control/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,18 +1,18 @@ import { eq, and } from "drizzle-orm" import { Database } from "@/storage/db" -import { ControlAccountTable } from "./control.sql" +import { AccountTable } from "./account.sql" import z from "zod" export * from "./control.sql" -export namespace Control { +export namespace Account { export const Account = z.object({ email: z.string(), url: z.string(), }) export type Account = z.infer - function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account { + function fromRow(row: (typeof AccountTable)["$inferSelect"]): Account { return { email: row.email, url: row.url, @@ -20,16 +20,12 @@ export namespace Control { } export function account(): Account | undefined { - const row = Database.use((db) => - db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(), - ) + const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get()) return row ? fromRow(row) : undefined } export async function token(): Promise { - const row = Database.use((db) => - db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(), - ) + const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get()) if (!row) return undefined if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token @@ -52,13 +48,13 @@ export namespace Control { Database.use((db) => db - .update(ControlAccountTable) + .update(AccountTable) .set({ access_token: json.access_token, refresh_token: json.refresh_token ?? row.refresh_token, token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined, }) - .where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url))) + .where(and(eq(AccountTable.email, row.email), eq(AccountTable.url, row.url))) .run(), ) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts new file mode 100644 index 0000000000..9563591641 --- /dev/null +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -0,0 +1,449 @@ +import { Auth } from "../../auth" +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { ModelsDev } from "../../provider/models" +import { map, pipe, sortBy, values } from "remeda" +import path from "path" +import os from "os" +import { Config } from "../../config/config" +import { Global } from "../../global" +import { Plugin } from "../../plugin" +import { Instance } from "../../project/instance" +import type { Hooks } from "@opencode-ai/plugin" +import { Process } from "../../util/process" +import { text } from "node:stream/consumers" + +type PluginAuth = NonNullable + +/** + * Handle plugin-based authentication flow. + * Returns true if auth was handled, false if it should fall through to default handling. + */ +async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise { + let index = 0 + 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) + } + const method = plugin.auth.methods[index] + + // Handle prompts for all auth types + await Bun.sleep(10) + const inputs: Record = {} + if (method.prompts) { + for (const prompt of method.prompts) { + if (prompt.condition && !prompt.condition(inputs)) { + continue + } + if (prompt.type === "select") { + const value = await prompts.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 + } + } + } + + if (method.type === "oauth") { + const authorize = await method.authorize(inputs) + + if (authorize.url) { + prompts.log.info("Go to: " + authorize.url) + } + + if (authorize.method === "auto") { + if (authorize.instructions) { + prompts.log.info(authorize.instructions) + } + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") + const result = await authorize.callback() + if (result.type === "failed") { + 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 Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + spinner.stop("Login successful") + } + } + + if (authorize.method === "code") { + const code = await prompts.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) + if (result.type === "failed") { + prompts.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 Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + prompts.log.success("Login successful") + } + } + + prompts.outro("Done") + return true + } + + if (method.type === "api") { + if (method.authorize) { + const result = await method.authorize(inputs) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + prompts.log.success("Login successful") + } + prompts.outro("Done") + return true + } + } + + return false +} + +/** + * Build a deduplicated list of plugin-registered auth providers that are not + * already present in models.dev, respecting enabled/disabled provider lists. + * Pure function with no side effects; safe to test without mocking. + */ +export function resolvePluginProviders(input: { + hooks: Hooks[] + existingProviders: Record + disabled: Set + enabled?: Set + providerNames: Record +}): Array<{ id: string; name: string }> { + const seen = new Set() + const result: Array<{ id: string; name: string }> = [] + + for (const hook of input.hooks) { + if (!hook.auth) continue + const id = hook.auth.provider + if (seen.has(id)) continue + seen.add(id) + if (Object.hasOwn(input.existingProviders, id)) continue + if (input.disabled.has(id)) continue + if (input.enabled && !input.enabled.has(id)) continue + result.push({ + id, + name: input.providerNames[id] ?? id, + }) + } + + return result +} + +export const AuthCommand = cmd({ + command: "auth", + describe: "manage credentials", + builder: (yargs) => + yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), + async handler() {}, +}) + +export const AuthListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list providers", + async handler() { + UI.empty() + 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}`) + const results = Object.entries(await Auth.all()) + const database = await ModelsDev.get() + + for (const [providerID, result] of results) { + const name = database[providerID]?.name || providerID + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + } + + prompts.outro(`${results.length} credentials`) + + // Environment variables section + const activeEnvVars: Array<{ provider: string; envVar: string }> = [] + + for (const [providerID, provider] of Object.entries(database)) { + for (const envVar of provider.env) { + if (process.env[envVar]) { + activeEnvVars.push({ + provider: provider.name || providerID, + envVar, + }) + } + } + } + + if (activeEnvVars.length > 0) { + UI.empty() + prompts.intro("Environment") + + for (const { provider, envVar } of activeEnvVars) { + prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + } + + prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + } + }, +}) + +export const AuthLoginCommand = cmd({ + command: "login [url]", + describe: "log in to a provider", + builder: (yargs) => + yargs.positional("url", { + describe: "opencode auth provider", + type: "string", + }), + async handler(args) { + await Instance.provide({ + directory: process.cwd(), + async fn() { + UI.empty() + prompts.intro("Add credential") + if (args.url) { + const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { + stdout: "pipe", + }) + if (!proc.stdout) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) + if (exit !== 0) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + await Auth.set(args.url, { + type: "wellknown", + key: wellknown.auth.env, + token: token.trim(), + }) + prompts.log.success("Logged into " + args.url) + prompts.outro("Done") + return + } + await ModelsDev.refresh().catch(() => {}) + + const config = await Config.get() + + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + + const providers = await ModelsDev.get().then((x) => { + const filtered: Record = {} + for (const [key, value] of Object.entries(x)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value + } + } + return filtered + }) + + const priority: Record = { + opencode: 0, + anthropic: 1, + "github-copilot": 2, + openai: 3, + google: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks: await Plugin.list(), + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + let provider = await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, + ), + map((x) => ({ + label: x.name, + value: x.id, + hint: { + opencode: "recommended", + anthropic: "Claude Max or API key", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], + })), + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + { + value: "other", + label: "Other", + }, + ], + }) + + if (prompts.isCancel(provider)) throw new UI.CancelledError() + + const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) + if (plugin && plugin.auth) { + const handled = await handlePluginAuth({ auth: plugin.auth }, provider) + if (handled) return + } + + if (provider === "other") { + provider = await prompts.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }) + if (prompts.isCancel(provider)) throw new UI.CancelledError() + provider = provider.replace(/^@ai-sdk\//, "") + if (prompts.isCancel(provider)) throw new UI.CancelledError() + + // Check if a plugin provides auth for this custom provider + const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider)) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) + if (handled) return + } + + prompts.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( + "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" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } + + if (provider === "opencode") { + prompts.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") + } + + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.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 = await prompts.password({ + message: "Enter your API key", + validate: (x) => (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: "log out 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 provider", + options: credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })), + }) + if (prompts.isCancel(providerID)) throw new UI.CancelledError() + await Auth.remove(providerID) + prompts.outro("Logout successful") + }, +}) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 141f615698..69d004c5f2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -32,7 +32,7 @@ import { Glob } from "../util/glob" import { PackageRegistry } from "@/bun/registry" import { proxied } from "@/util/proxied" import { iife } from "@/util/iife" -import { Control } from "@/control" +import { Account } from "@/account" import { ConfigPaths } from "./paths" import { Filesystem } from "@/util/filesystem" @@ -107,7 +107,7 @@ export namespace Config { } } - const token = await Control.token() + const token = await Account.token() if (token) { } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index f29aac18d1..b8f37b20a9 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -11,7 +11,6 @@ import { NamedError } from "@opencode-ai/util/error" import z from "zod" import path from "path" import { readFileSync, readdirSync, existsSync } from "fs" -import * as schema from "./schema" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined @@ -26,10 +25,8 @@ const log = Log.create({ service: "db" }) export namespace Database { export const Path = path.join(Global.Path.data, "opencode.db") - type Schema = typeof schema - export type Transaction = SQLiteTransaction<"sync", void, Schema> - type Client = SQLiteBunDatabase + type Client = SQLiteBunDatabase type Journal = { sql: string; timestamp: number }[] @@ -82,7 +79,7 @@ export namespace Database { sqlite.run("PRAGMA foreign_keys = ON") sqlite.run("PRAGMA wal_checkpoint(PASSIVE)") - const db = drizzle({ client: sqlite, schema }) + const db = drizzle({ client: sqlite }) // Apply schema migrations const entries = @@ -108,7 +105,7 @@ export namespace Database { Client.reset() } - export type TxOrDb = Transaction | Client + export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client const ctx = Context.create<{ tx: TxOrDb diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts deleted file mode 100644 index 4c1c2490e3..0000000000 --- a/packages/opencode/src/storage/schema.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { ControlAccountTable } from "../control/control.sql" -export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" -export { SessionShareTable } from "../share/share.sql" -export { ProjectTable } from "../project/project.sql" -export { WorkspaceTable } from "../control-plane/workspace.sql"