From 9dbf3a204299c2650deff792597bb2f7ea00f11f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 13:48:58 -0500 Subject: [PATCH 01/10] core: rename auth command to providers for clearer credential management The auth command has been renamed to providers to better reflect its purpose of managing AI provider credentials. This makes it easier for users to discover and use the credential management features when configuring different AI providers. --- AGENTS.md | 4 +++ .../src/cli/cmd/{auth.ts => providers.ts} | 33 +++++++------------ packages/opencode/src/index.ts | 4 +-- .../test/cli/plugin-auth-picker.test.ts | 2 +- turbo.json | 2 +- 5 files changed, 19 insertions(+), 26 deletions(-) rename packages/opencode/src/cli/cmd/{auth.ts => providers.ts} (93%) diff --git a/AGENTS.md b/AGENTS.md index 758714d10aa..074a9305f34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,3 +111,7 @@ const table = sqliteTable("session", { - Avoid mocks as much as possible - Test actual implementation, do not duplicate logic into tests - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. + +## Type Checking + +- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly. diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/providers.ts similarity index 93% rename from packages/opencode/src/cli/cmd/auth.ts rename to packages/opencode/src/cli/cmd/providers.ts index 95635916413..499a6f0e360 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -16,10 +16,6 @@ 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) { @@ -37,7 +33,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): } const method = plugin.auth.methods[index] - // Handle prompts for all auth types await Bun.sleep(10) const inputs: Record = {} if (method.prompts) { @@ -161,11 +156,6 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): 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 @@ -193,19 +183,20 @@ export function resolvePluginProviders(input: { return result } -export const AuthCommand = cmd({ - command: "auth", - describe: "manage credentials", +export const ProvidersCommand = cmd({ + command: "providers", + aliases: ["auth"], + describe: "manage AI providers and credentials", builder: (yargs) => - yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(), + yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(), async handler() {}, }) -export const AuthListCommand = cmd({ +export const ProvidersListCommand = cmd({ command: "list", aliases: ["ls"], - describe: "list providers", - async handler() { + describe: "list providers and credentials", + async handler(_args) { UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() @@ -221,7 +212,6 @@ export const AuthListCommand = cmd({ prompts.outro(`${results.length} credentials`) - // Environment variables section const activeEnvVars: Array<{ provider: string; envVar: string }> = [] for (const [providerID, provider] of Object.entries(database)) { @@ -248,7 +238,7 @@ export const AuthListCommand = cmd({ }, }) -export const AuthLoginCommand = cmd({ +export const ProvidersLoginCommand = cmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => @@ -371,7 +361,6 @@ export const AuthLoginCommand = cmd({ 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) @@ -423,10 +412,10 @@ export const AuthLoginCommand = cmd({ }, }) -export const AuthLogoutCommand = cmd({ +export const ProvidersLogoutCommand = cmd({ command: "logout", describe: "log out from a configured provider", - async handler() { + async handler(_args) { UI.empty() const credentials = await Auth.all().then((x) => Object.entries(x)) prompts.intro("Remove credential") diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 35b42dce77c..66f5e4c832b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,7 +3,7 @@ import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run" import { GenerateCommand } from "./cli/cmd/generate" import { Log } from "./util/log" -import { AuthCommand } from "./cli/cmd/auth" +import { ProvidersCommand } from "./cli/cmd/providers" import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" import { UninstallCommand } from "./cli/cmd/uninstall" @@ -129,7 +129,7 @@ let cli = yargs(hideBin(process.argv)) .command(RunCommand) .command(GenerateCommand) .command(DebugCommand) - .command(AuthCommand) + .command(ProvidersCommand) .command(AgentCommand) .command(UpgradeCommand) .command(UninstallCommand) diff --git a/packages/opencode/test/cli/plugin-auth-picker.test.ts b/packages/opencode/test/cli/plugin-auth-picker.test.ts index 3ce9094e92c..5a1cf059d5e 100644 --- a/packages/opencode/test/cli/plugin-auth-picker.test.ts +++ b/packages/opencode/test/cli/plugin-auth-picker.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe } from "bun:test" -import { resolvePluginProviders } from "../../src/cli/cmd/auth" +import { resolvePluginProviders } from "../../src/cli/cmd/providers" import type { Hooks } from "@opencode-ai/plugin" function hookWithAuth(provider: string): Hooks { diff --git a/turbo.json b/turbo.json index ba3d01d3603..dba35379d85 100644 --- a/turbo.json +++ b/turbo.json @@ -4,7 +4,7 @@ "globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"], "tasks": { "typecheck": { - "dependsOn": ["^build"] + "dependsOn": [] }, "build": { "dependsOn": ["^build"], From d16e5b98dcc9bb443ee8e56cdc96dd7b53bbc06a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 13:54:04 -0500 Subject: [PATCH 02/10] 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 00000000000..9a259f5bd9f --- /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 00000000000..625fd354303 --- /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 7b805c16274..84c383401f5 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 f712e88281f..c59eef54fdc 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 00000000000..95635916413 --- /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 141f6156985..69d004c5f2a 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 f29aac18d16..b8f37b20a95 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 4c1c2490e37..00000000000 --- 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" From b5515dd2f7378713b755ab367ec986c79efc747c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 14:17:43 -0500 Subject: [PATCH 03/10] core: add device flow authentication commands Allow users to authenticate via browser-based OAuth device flow with opencode login command. Includes login, logout, switch account, and workspaces list commands for managing multiple accounts. --- packages/opencode/src/account/index.ts | 132 ++++++++++++++++++++++- packages/opencode/src/cli/cmd/account.ts | 89 +++++++++++++++ packages/opencode/src/index.ts | 5 + 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/account.ts diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index c59eef54fdc..630f9907247 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -3,8 +3,6 @@ import { Database } from "@/storage/db" import { AccountTable } from "./account.sql" import z from "zod" -export * from "./control.sql" - export namespace Account { export const Account = z.object({ email: z.string(), @@ -60,4 +58,134 @@ export namespace Account { return json.access_token } + + export type Login = { + code: string + user: string + url: string + server: string + expiry: number + interval: number + } + + export async function login(url?: string): Promise { + const server = url ?? "https://web-14275-d60e67f5-pyqs0590.onporter.run" + const res = await fetch(`${server}/auth/device/code`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_id: "opencode-cli" }), + }) + + if (!res.ok) throw new Error(`Failed to initiate device flow: ${await res.text()}`) + + const json = (await res.json()) as { + device_code: string + user_code: string + verification_uri_complete: string + expires_in: number + interval: number + } + + const full = `${server}${json.verification_uri_complete}` + + return { + code: json.device_code, + user: json.user_code, + url: full, + server, + expiry: json.expires_in, + interval: json.interval, + } + } + + export async function poll( + input: Login, + ): Promise< + | { type: "success"; email: string } + | { type: "pending" } + | { type: "slow" } + | { type: "expired" } + | { type: "denied" } + | { type: "error"; msg: string } + > { + const res = await fetch(`${input.server}/auth/device/token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: input.code, + client_id: "opencode-cli", + }), + }) + + const json = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + email?: string + error?: string + error_description?: string + } + + if (json.access_token) { + let email = json.email + if (!email) { + const me = await fetch(`${input.server}/api/user`, { + headers: { authorization: `Bearer ${json.access_token}` }, + }) + const user = (await me.json()) as { email?: string } + if (!user.email) { + return { type: "error", msg: "No email in response" } + } + email = user.email + } + + const access = json.access_token + const expiry = Date.now() + json.expires_in! * 1000 + const refresh = json.refresh_token ?? "" + + Database.use((db) => { + db.update(AccountTable).set({ active: false }).run() + db.insert(AccountTable) + .values({ + email, + url: input.url, + access_token: access, + refresh_token: refresh, + token_expiry: expiry, + active: true, + }) + .onConflictDoUpdate({ + target: [AccountTable.email, AccountTable.url], + set: { + access_token: access, + refresh_token: refresh, + token_expiry: expiry, + active: true, + }, + }) + .run() + }) + + return { type: "success", email } + } + + if (json.error === "authorization_pending") { + return { type: "pending" } + } + + if (json.error === "slow_down") { + return { type: "slow" } + } + + if (json.error === "expired_token") { + return { type: "expired" } + } + + if (json.error === "access_denied") { + return { type: "denied" } + } + + return { type: "error", msg: json.error || JSON.stringify(json) } + } } diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts new file mode 100644 index 00000000000..00fbcf2085e --- /dev/null +++ b/packages/opencode/src/cli/cmd/account.ts @@ -0,0 +1,89 @@ +import { cmd } from "./cmd" +import * as prompts from "@clack/prompts" +import { UI } from "../ui" +import { Account } from "@/account" + +export const LoginCommand = cmd({ + command: "login [url]", + describe: "log in to an opencode account", + builder: (yargs) => + yargs.positional("url", { + describe: "server URL", + type: "string", + }), + async handler(args) { + UI.empty() + prompts.intro("Log in") + + const url = args.url as string | undefined + const login = await Account.login(url) + + prompts.log.info("Go to: " + login.url) + prompts.log.info("Enter code: " + login.user) + + try { + const open = + process.platform === "darwin" + ? ["open", login.url] + : process.platform === "win32" + ? ["cmd", "/c", "start", login.url] + : ["xdg-open", login.url] + Bun.spawn(open, { stdout: "ignore", stderr: "ignore" }) + } catch {} + + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") + + let wait = login.interval * 1000 + while (true) { + await Bun.sleep(wait) + + const result = await Account.poll(login) + + if (result.type === "success") { + spinner.stop("Logged in as " + result.email) + prompts.outro("Done") + return + } + + if (result.type === "pending") continue + + if (result.type === "slow") { + wait += 5000 + continue + } + + if (result.type === "expired") { + spinner.stop("Device code expired", 1) + return + } + + if (result.type === "denied") { + spinner.stop("Authorization denied", 1) + return + } + + spinner.stop("Error: " + result.msg, 1) + return + } + }, +}) + +export const LogoutCommand = cmd({ + command: "logout", + describe: "log out from an account", + async handler() {}, +}) + +export const SwitchCommand = cmd({ + command: "switch", + describe: "switch active workspace", + async handler() {}, +}) + +export const WorkspacesCommand = cmd({ + command: "workspaces", + aliases: ["workspace"], + describe: "list all workspaces", + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 66f5e4c832b..e4b85e3902c 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,6 +3,7 @@ import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run" import { GenerateCommand } from "./cli/cmd/generate" import { Log } from "./util/log" +import { LoginCommand, LogoutCommand, SwitchCommand, WorkspacesCommand } from "./cli/cmd/account" import { ProvidersCommand } from "./cli/cmd/providers" import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" @@ -129,6 +130,10 @@ let cli = yargs(hideBin(process.argv)) .command(RunCommand) .command(GenerateCommand) .command(DebugCommand) + .command(LoginCommand) + .command(LogoutCommand) + .command(SwitchCommand) + .command(WorkspacesCommand) .command(ProvidersCommand) .command(AgentCommand) .command(UpgradeCommand) From 7b5b665b4a5cd3cddabd4b77b38a2b9810652e52 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 14:23:55 -0500 Subject: [PATCH 04/10] core: support managing multiple authenticated accounts with individual workspace access Enable users to authenticate with multiple accounts and switch between them, accessing workspaces from each account separately. --- .../20260228192110_account_id/migration.sql | 18 + .../20260228192110_account_id/snapshot.json | 1018 +++++++++++++++++ packages/opencode/src/account/account.sql.ts | 32 +- packages/opencode/src/account/index.ts | 55 +- packages/opencode/src/config/config.ts | 3 +- 5 files changed, 1089 insertions(+), 37 deletions(-) create mode 100644 packages/opencode/migration/20260228192110_account_id/migration.sql create mode 100644 packages/opencode/migration/20260228192110_account_id/snapshot.json diff --git a/packages/opencode/migration/20260228192110_account_id/migration.sql b/packages/opencode/migration/20260228192110_account_id/migration.sql new file mode 100644 index 00000000000..adb374e2215 --- /dev/null +++ b/packages/opencode/migration/20260228192110_account_id/migration.sql @@ -0,0 +1,18 @@ +ALTER TABLE `account` ADD `id` text;--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_account` ( + `id` text PRIMARY KEY, + `email` text NOT NULL, + `url` text NOT NULL, + `access_token` text NOT NULL, + `refresh_token` text NOT NULL, + `token_expiry` integer, + `active` integer NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_account`(`email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated`) SELECT `email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated` FROM `account`;--> statement-breakpoint +DROP TABLE `account`;--> statement-breakpoint +ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/packages/opencode/migration/20260228192110_account_id/snapshot.json b/packages/opencode/migration/20260228192110_account_id/snapshot.json new file mode 100644 index 00000000000..b3f8588eeeb --- /dev/null +++ b/packages/opencode/migration/20260228192110_account_id/snapshot.json @@ -0,0 +1,1018 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "b8dc7400-9dbb-4556-b353-68e6b3d4906e", + "prevIds": [ + "3200a36f-5de6-4b78-9f8c-b8553fbe64f6" + ], + "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": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "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": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "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": [] +} \ No newline at end of file diff --git a/packages/opencode/src/account/account.sql.ts b/packages/opencode/src/account/account.sql.ts index 84c383401f5..3073ca02f12 100644 --- a/packages/opencode/src/account/account.sql.ts +++ b/packages/opencode/src/account/account.sql.ts @@ -1,21 +1,15 @@ -import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { Timestamps } from "@/storage/schema.sql" -export const AccountTable = sqliteTable( - "account", - { - email: text().notNull(), - url: text().notNull(), - access_token: text().notNull(), - refresh_token: text().notNull(), - token_expiry: integer(), - active: integer({ mode: "boolean" }) - .notNull() - .$default(() => false), - ...Timestamps, - }, - (table) => [ - primaryKey({ columns: [table.email, table.url] }), - // uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)), - ], -) +export const AccountTable = sqliteTable("account", { + id: text().primaryKey(), + email: text().notNull(), + url: text().notNull(), + access_token: text().notNull(), + refresh_token: text().notNull(), + token_expiry: integer(), + active: integer({ mode: "boolean" }) + .notNull() + .$default(() => false), + ...Timestamps, +}) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 630f9907247..47ef6ebbfd0 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,10 +1,11 @@ -import { eq, and } from "drizzle-orm" +import { eq } from "drizzle-orm" import { Database } from "@/storage/db" import { AccountTable } from "./account.sql" import z from "zod" export namespace Account { export const Account = z.object({ + id: z.string(), email: z.string(), url: z.string(), }) @@ -12,18 +13,40 @@ export namespace Account { function fromRow(row: (typeof AccountTable)["$inferSelect"]): Account { return { + id: row.id, email: row.email, url: row.url, } } - export function account(): Account | undefined { + export function active(): Account | undefined { 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(AccountTable).where(eq(AccountTable.active, true)).get()) + export function list(): Account[] { + return Database.use((db) => db.select().from(AccountTable).all().map(fromRow)) + } + + export async function workspaces(accountID: string): Promise<{ id: string; name: string }[]> { + const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()) + if (!row) return [] + + const access = await token(accountID) + if (!access) return [] + + const res = await fetch(`${row.url}/api/orgs`, { + headers: { authorization: `Bearer ${access}` }, + }) + + if (!res.ok) return [] + + const json = (await res.json()) as Array<{ id?: string; name?: string }> + return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" })) + } + + export async function token(accountID: string): Promise { + const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()) if (!row) return undefined if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token @@ -52,7 +75,7 @@ export namespace Account { refresh_token: json.refresh_token ?? row.refresh_token, token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined, }) - .where(and(eq(AccountTable.email, row.email), eq(AccountTable.url, row.url))) + .where(eq(AccountTable.id, row.id)) .run(), ) @@ -122,23 +145,20 @@ export namespace Account { access_token?: string refresh_token?: string expires_in?: number - email?: string error?: string error_description?: string } if (json.access_token) { - let email = json.email - if (!email) { - const me = await fetch(`${input.server}/api/user`, { - headers: { authorization: `Bearer ${json.access_token}` }, - }) - const user = (await me.json()) as { email?: string } - if (!user.email) { - return { type: "error", msg: "No email in response" } - } - email = user.email + const me = await fetch(`${input.server}/api/user`, { + headers: { authorization: `Bearer ${json.access_token}` }, + }) + const user = (await me.json()) as { id?: string; email?: string } + if (!user.id || !user.email) { + return { type: "error", msg: "No id or email in response" } } + const id = user.id + const email = user.email const access = json.access_token const expiry = Date.now() + json.expires_in! * 1000 @@ -148,6 +168,7 @@ export namespace Account { db.update(AccountTable).set({ active: false }).run() db.insert(AccountTable) .values({ + id, email, url: input.url, access_token: access, @@ -156,7 +177,7 @@ export namespace Account { active: true, }) .onConflictDoUpdate({ - target: [AccountTable.email, AccountTable.url], + target: AccountTable.id, set: { access_token: access, refresh_token: refresh, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 69d004c5f2a..5e458b7bd0a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -107,7 +107,8 @@ export namespace Config { } } - const token = await Account.token() + const active = Account.active() + const token = active ? await Account.token(active.id) : undefined if (token) { } From a5d727e7f972dd0b013d4a347466777482730d55 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 15:30:42 -0500 Subject: [PATCH 05/10] core: enable workspace-aware configuration and account management commands Switch from boolean active flag to workspace_id tracking so users can select which workspace context to operate in. Login now automatically selects the first available workspace and stores it on the account record. Logout command now actually removes account records and supports targeting specific accounts by email. Switch command provides an interactive picker to change active workspace. Workspaces command lists all available workspaces across accounts. Configuration now loads workspace-specific settings from the server when an active workspace is selected, enabling per-workspace customization of opencode behavior. --- .../migration.sql | 2 + .../snapshot.json | 1018 +++++++++++++++++ packages/opencode/src/account/account.sql.ts | 4 +- packages/opencode/src/account/index.ts | 47 +- packages/opencode/src/cli/cmd/account.ts | 80 +- packages/opencode/src/config/config.ts | 14 +- 6 files changed, 1147 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/migration/20260228200329_account-workspace-id/migration.sql create mode 100644 packages/opencode/migration/20260228200329_account-workspace-id/snapshot.json diff --git a/packages/opencode/migration/20260228200329_account-workspace-id/migration.sql b/packages/opencode/migration/20260228200329_account-workspace-id/migration.sql new file mode 100644 index 00000000000..7572fed8b82 --- /dev/null +++ b/packages/opencode/migration/20260228200329_account-workspace-id/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `account` ADD `workspace_id` text;--> statement-breakpoint +ALTER TABLE `account` DROP COLUMN `active`; \ No newline at end of file diff --git a/packages/opencode/migration/20260228200329_account-workspace-id/snapshot.json b/packages/opencode/migration/20260228200329_account-workspace-id/snapshot.json new file mode 100644 index 00000000000..e46b5fe1fa6 --- /dev/null +++ b/packages/opencode/migration/20260228200329_account-workspace-id/snapshot.json @@ -0,0 +1,1018 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "1f59b6d9-6292-4cbd-8db1-7e5631d46b77", + "prevIds": [ + "b8dc7400-9dbb-4556-b353-68e6b3d4906e" + ], + "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": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "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": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "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": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "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": [] +} \ No newline at end of file diff --git a/packages/opencode/src/account/account.sql.ts b/packages/opencode/src/account/account.sql.ts index 3073ca02f12..67dfccf6ab1 100644 --- a/packages/opencode/src/account/account.sql.ts +++ b/packages/opencode/src/account/account.sql.ts @@ -8,8 +8,6 @@ export const AccountTable = sqliteTable("account", { access_token: text().notNull(), refresh_token: text().notNull(), token_expiry: integer(), - active: integer({ mode: "boolean" }) - .notNull() - .$default(() => false), + workspace_id: text(), ...Timestamps, }) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 47ef6ebbfd0..d6b503cf33b 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm" +import { eq, sql, isNotNull } from "drizzle-orm" import { Database } from "@/storage/db" import { AccountTable } from "./account.sql" import z from "zod" @@ -8,6 +8,7 @@ export namespace Account { id: z.string(), email: z.string(), url: z.string(), + workspace_id: z.string().nullable(), }) export type Account = z.infer @@ -16,11 +17,12 @@ export namespace Account { id: row.id, email: row.email, url: row.url, + workspace_id: row.workspace_id, } } export function active(): Account | undefined { - const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get()) + const row = Database.use((db) => db.select().from(AccountTable).where(isNotNull(AccountTable.workspace_id)).get()) return row ? fromRow(row) : undefined } @@ -28,6 +30,16 @@ export namespace Account { return Database.use((db) => db.select().from(AccountTable).all().map(fromRow)) } + export function remove(accountID: string) { + Database.use((db) => db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()) + } + + export function use(accountID: string, workspaceID: string | null) { + Database.use((db) => + db.update(AccountTable).set({ workspace_id: workspaceID }).where(eq(AccountTable.id, accountID)).run(), + ) + } + export async function workspaces(accountID: string): Promise<{ id: string; name: string }[]> { const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()) if (!row) return [] @@ -45,6 +57,22 @@ export namespace Account { return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" })) } + export async function config(accountID: string, workspaceID: string): Promise | undefined> { + const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()) + if (!row) return undefined + + const access = await token(accountID) + if (!access) return undefined + + const res = await fetch(`${row.url}/api/config`, { + headers: { authorization: `Bearer ${access}`, "x-org-id": workspaceID }, + }) + + if (!res.ok) return undefined + const result = (await res.json()) as Record + return result.config + } + export async function token(accountID: string): Promise { const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()) if (!row) return undefined @@ -164,17 +192,24 @@ export namespace Account { const expiry = Date.now() + json.expires_in! * 1000 const refresh = json.refresh_token ?? "" + // Fetch workspaces and get first one + const orgsRes = await fetch(`${input.server}/api/orgs`, { + headers: { authorization: `Bearer ${access}` }, + }) + const orgs = (await orgsRes.json()) as Array<{ id?: string; name?: string }> + const firstWorkspaceId = orgs.length > 0 ? orgs[0].id : null + Database.use((db) => { - db.update(AccountTable).set({ active: false }).run() + db.update(AccountTable).set({ workspace_id: null }).run() db.insert(AccountTable) .values({ id, email, - url: input.url, + url: input.server, access_token: access, refresh_token: refresh, token_expiry: expiry, - active: true, + workspace_id: firstWorkspaceId, }) .onConflictDoUpdate({ target: AccountTable.id, @@ -182,7 +217,7 @@ export namespace Account { access_token: access, refresh_token: refresh, token_expiry: expiry, - active: true, + workspace_id: firstWorkspaceId, }, }) .run() diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 00fbcf2085e..ef1c64c8065 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -70,20 +70,92 @@ export const LoginCommand = cmd({ }) export const LogoutCommand = cmd({ - command: "logout", + command: "logout [email]", describe: "log out from an account", - async handler() {}, + builder: (yargs) => + yargs.positional("email", { + describe: "account email to log out from", + type: "string", + }), + async handler(args) { + const email = args.email as string | undefined + + if (email) { + const accounts = Account.list() + const match = accounts.find((a) => a.email === email) + if (!match) { + UI.println("Account not found: " + email) + return + } + Account.remove(match.id) + UI.println("Logged out from " + email) + return + } + + const active = Account.active() + if (!active) { + UI.println("Not logged in") + return + } + Account.remove(active.id) + UI.println("Logged out from " + active.email) + }, }) export const SwitchCommand = cmd({ command: "switch", describe: "switch active workspace", - async handler() {}, + async handler() { + UI.empty() + + const active = Account.active() + if (!active) { + UI.println("Not logged in") + return + } + + const workspaces = await Account.workspaces(active.id) + if (workspaces.length === 0) { + UI.println("No workspaces found") + return + } + + prompts.intro("Switch workspace") + + const opts = workspaces.map((w) => ({ + value: w.id, + label: w.id === active.workspace_id ? w.name + UI.Style.TEXT_DIM + " (active)" : w.name, + })) + + const selected = await prompts.select({ + message: "Select workspace", + options: opts, + }) + + if (prompts.isCancel(selected)) return + + Account.use(active.id, selected as string) + prompts.outro("Switched to " + workspaces.find((w) => w.id === selected)?.name) + }, }) export const WorkspacesCommand = cmd({ command: "workspaces", aliases: ["workspace"], describe: "list all workspaces", - async handler() {}, + async handler() { + const accounts = Account.list() + + if (accounts.length === 0) { + UI.println("No accounts found") + return + } + + for (const account of accounts) { + const workspaces = await Account.workspaces(account.id) + for (const space of workspaces) { + UI.println([space.name, account.email, space.id].join("\t")) + } + } + }, }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5e458b7bd0a..405bd8b9793 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -107,11 +107,6 @@ export namespace Config { } } - const active = Account.active() - const token = active ? await Account.token(active.id) : undefined - if (token) { - } - // Global user config overrides remote config. result = mergeConfigConcatArrays(result, await global()) @@ -178,6 +173,15 @@ export namespace Config { log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } + const active = Account.active() + if (active?.workspace_id) { + const config = await Account.config(active.id, active.workspace_id) + result = mergeConfigConcatArrays(result, config ?? {}) + const token = await Account.token(active.id) + // TODO: this is bad + process.env["OPENCODE_CONTROL_TOKEN"] = token + } + // Load managed config files last (highest priority) - enterprise admin-controlled // Kept separate from directories array to avoid write operations when installing plugins // which would fail on system directories requiring elevated permissions From a44f78c34af4bcd5f3d474acca88f4ca25b7cdd1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 28 Feb 2026 15:33:33 -0500 Subject: [PATCH 06/10] core: maintain backward compatibility with existing account data by restoring legacy ControlAccountTable alongside new AccountTable structure --- .../migration.sql | 1 - .../snapshot.json | 1011 ---------------- .../20260228192110_account_id/migration.sql | 18 - .../20260228192110_account_id/snapshot.json | 1018 ----------------- .../migration.sql | 2 - .../20260228203230_blue_harpoon/migration.sql | 11 + .../snapshot.json | 98 +- packages/opencode/src/account/account.sql.ts | 22 +- 8 files changed, 128 insertions(+), 2053 deletions(-) delete mode 100644 packages/opencode/migration/20260228185036_shocking_namor/migration.sql delete mode 100644 packages/opencode/migration/20260228185036_shocking_namor/snapshot.json delete mode 100644 packages/opencode/migration/20260228192110_account_id/migration.sql delete mode 100644 packages/opencode/migration/20260228192110_account_id/snapshot.json delete mode 100644 packages/opencode/migration/20260228200329_account-workspace-id/migration.sql create mode 100644 packages/opencode/migration/20260228203230_blue_harpoon/migration.sql rename packages/opencode/migration/{20260228200329_account-workspace-id => 20260228203230_blue_harpoon}/snapshot.json (90%) diff --git a/packages/opencode/migration/20260228185036_shocking_namor/migration.sql b/packages/opencode/migration/20260228185036_shocking_namor/migration.sql deleted file mode 100644 index 9a259f5bd9f..00000000000 --- a/packages/opencode/migration/20260228185036_shocking_namor/migration.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 625fd354303..00000000000 --- a/packages/opencode/migration/20260228185036_shocking_namor/snapshot.json +++ /dev/null @@ -1,1011 +0,0 @@ -{ - "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/migration/20260228192110_account_id/migration.sql b/packages/opencode/migration/20260228192110_account_id/migration.sql deleted file mode 100644 index adb374e2215..00000000000 --- a/packages/opencode/migration/20260228192110_account_id/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ -ALTER TABLE `account` ADD `id` text;--> statement-breakpoint -PRAGMA foreign_keys=OFF;--> statement-breakpoint -CREATE TABLE `__new_account` ( - `id` text PRIMARY KEY, - `email` text NOT NULL, - `url` text NOT NULL, - `access_token` text NOT NULL, - `refresh_token` text NOT NULL, - `token_expiry` integer, - `active` integer NOT NULL, - `time_created` integer NOT NULL, - `time_updated` integer NOT NULL -); ---> statement-breakpoint -INSERT INTO `__new_account`(`email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated`) SELECT `email`, `url`, `access_token`, `refresh_token`, `token_expiry`, `active`, `time_created`, `time_updated` FROM `account`;--> statement-breakpoint -DROP TABLE `account`;--> statement-breakpoint -ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint -PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/packages/opencode/migration/20260228192110_account_id/snapshot.json b/packages/opencode/migration/20260228192110_account_id/snapshot.json deleted file mode 100644 index b3f8588eeeb..00000000000 --- a/packages/opencode/migration/20260228192110_account_id/snapshot.json +++ /dev/null @@ -1,1018 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "b8dc7400-9dbb-4556-b353-68e6b3d4906e", - "prevIds": [ - "3200a36f-5de6-4b78-9f8c-b8553fbe64f6" - ], - "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": false, - "autoincrement": false, - "default": null, - "generated": null, - "name": "id", - "entityType": "columns", - "table": "account" - }, - { - "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": [ - "session_id", - "position" - ], - "nameExplicit": false, - "name": "todo_pk", - "entityType": "pks", - "table": "todo" - }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "account_pk", - "table": "account", - "entityType": "pks" - }, - { - "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": [] -} \ No newline at end of file diff --git a/packages/opencode/migration/20260228200329_account-workspace-id/migration.sql b/packages/opencode/migration/20260228200329_account-workspace-id/migration.sql deleted file mode 100644 index 7572fed8b82..00000000000 --- a/packages/opencode/migration/20260228200329_account-workspace-id/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE `account` ADD `workspace_id` text;--> statement-breakpoint -ALTER TABLE `account` DROP COLUMN `active`; \ No newline at end of file diff --git a/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql b/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql new file mode 100644 index 00000000000..19ce1e5f6f4 --- /dev/null +++ b/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE `account` ( + `id` text PRIMARY KEY, + `email` text NOT NULL, + `url` text NOT NULL, + `access_token` text NOT NULL, + `refresh_token` text NOT NULL, + `token_expiry` integer, + `workspace_id` text, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); diff --git a/packages/opencode/migration/20260228200329_account-workspace-id/snapshot.json b/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json similarity index 90% rename from packages/opencode/migration/20260228200329_account-workspace-id/snapshot.json rename to packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json index e46b5fe1fa6..4fb8465f6ec 100644 --- a/packages/opencode/migration/20260228200329_account-workspace-id/snapshot.json +++ b/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json @@ -1,15 +1,19 @@ { "version": "7", "dialect": "sqlite", - "id": "1f59b6d9-6292-4cbd-8db1-7e5631d46b77", + "id": "325559b7-104f-4d2a-a02c-934cfad7cfcc", "prevIds": [ - "b8dc7400-9dbb-4556-b353-68e6b3d4906e" + "1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40" ], "ddl": [ { "name": "account", "entityType": "tables" }, + { + "name": "control_account", + "entityType": "tables" + }, { "name": "workspace", "entityType": "tables" @@ -132,6 +136,86 @@ "entityType": "columns", "table": "account" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, { "type": "text", "notNull": false, @@ -847,6 +931,16 @@ "entityType": "fks", "table": "session_share" }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, { "columns": [ "session_id", diff --git a/packages/opencode/src/account/account.sql.ts b/packages/opencode/src/account/account.sql.ts index 67dfccf6ab1..7f6ba97ae9e 100644 --- a/packages/opencode/src/account/account.sql.ts +++ b/packages/opencode/src/account/account.sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core" import { Timestamps } from "@/storage/schema.sql" export const AccountTable = sqliteTable("account", { @@ -11,3 +11,23 @@ export const AccountTable = sqliteTable("account", { workspace_id: text(), ...Timestamps, }) + +// LEGACY +export const ControlAccountTable = sqliteTable( + "control_account", + { + email: text().notNull(), + url: text().notNull(), + access_token: text().notNull(), + refresh_token: text().notNull(), + token_expiry: integer(), + active: integer({ mode: "boolean" }) + .notNull() + .$default(() => false), + ...Timestamps, + }, + (table) => [ + primaryKey({ columns: [table.email, table.url] }), + // uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)), + ], +) From 902268e0d13e98158c2b3ff65dd10e5c513c1a40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 5 Mar 2026 16:02:17 -0500 Subject: [PATCH 07/10] core: rename workspace scope to org across account and session surfaces --- .../migration.sql | 2 ++ .../snapshot.json | 6 ++-- .../migration.sql | 2 -- .../20260228203230_blue_harpoon/migration.sql | 2 +- .../20260228203230_blue_harpoon/snapshot.json | 2 +- packages/opencode/src/account/account.sql.ts | 2 +- packages/opencode/src/account/index.ts | 26 +++++++------- packages/opencode/src/cli/cmd/account.ts | 34 +++++++++---------- packages/opencode/src/config/config.ts | 4 +-- packages/opencode/src/index.ts | 4 +-- packages/opencode/src/session/index.ts | 12 +++---- packages/opencode/src/session/session.sql.ts | 4 +-- packages/sdk/js/src/v2/gen/types.gen.ts | 4 +-- packages/sdk/openapi.json | 4 +-- 14 files changed, 54 insertions(+), 54 deletions(-) create mode 100644 packages/opencode/migration/20260227213759_add_session_org_id/migration.sql rename packages/opencode/migration/{20260227213759_add_session_workspace_id => 20260227213759_add_session_org_id}/snapshot.json (99%) delete mode 100644 packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql diff --git a/packages/opencode/migration/20260227213759_add_session_org_id/migration.sql b/packages/opencode/migration/20260227213759_add_session_org_id/migration.sql new file mode 100644 index 00000000000..7513b0af386 --- /dev/null +++ b/packages/opencode/migration/20260227213759_add_session_org_id/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `session` ADD `org_id` text;--> statement-breakpoint +CREATE INDEX `session_org_idx` ON `session` (`org_id`); \ No newline at end of file diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json b/packages/opencode/migration/20260227213759_add_session_org_id/snapshot.json similarity index 99% rename from packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json rename to packages/opencode/migration/20260227213759_add_session_org_id/snapshot.json index 8cd94d00527..751dbfe5e5a 100644 --- a/packages/opencode/migration/20260227213759_add_session_workspace_id/snapshot.json +++ b/packages/opencode/migration/20260227213759_add_session_org_id/snapshot.json @@ -446,7 +446,7 @@ "autoincrement": false, "default": null, "generated": null, - "name": "workspace_id", + "name": "org_id", "entityType": "columns", "table": "session" }, @@ -939,14 +939,14 @@ { "columns": [ { - "value": "workspace_id", + "value": "org_id", "isExpression": false } ], "isUnique": false, "where": null, "origin": "manual", - "name": "session_workspace_idx", + "name": "session_org_idx", "entityType": "indexes", "table": "session" }, diff --git a/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql b/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql deleted file mode 100644 index f5488af2180..00000000000 --- a/packages/opencode/migration/20260227213759_add_session_workspace_id/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE `session` ADD `workspace_id` text;--> statement-breakpoint -CREATE INDEX `session_workspace_idx` ON `session` (`workspace_id`); \ No newline at end of file diff --git a/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql b/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql index 19ce1e5f6f4..897c0f473e7 100644 --- a/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql +++ b/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql @@ -5,7 +5,7 @@ CREATE TABLE `account` ( `access_token` text NOT NULL, `refresh_token` text NOT NULL, `token_expiry` integer, - `workspace_id` text, + `org_id` text, `time_created` integer NOT NULL, `time_updated` integer NOT NULL ); diff --git a/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json b/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json index 4fb8465f6ec..108641acc11 100644 --- a/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json +++ b/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json @@ -112,7 +112,7 @@ "autoincrement": false, "default": null, "generated": null, - "name": "workspace_id", + "name": "org_id", "entityType": "columns", "table": "account" }, diff --git a/packages/opencode/src/account/account.sql.ts b/packages/opencode/src/account/account.sql.ts index 7f6ba97ae9e..deeb8a4fcfc 100644 --- a/packages/opencode/src/account/account.sql.ts +++ b/packages/opencode/src/account/account.sql.ts @@ -8,7 +8,7 @@ export const AccountTable = sqliteTable("account", { access_token: text().notNull(), refresh_token: text().notNull(), token_expiry: integer(), - workspace_id: text(), + org_id: text(), ...Timestamps, }) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index d6b503cf33b..3bb02c057a5 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -8,7 +8,7 @@ export namespace Account { id: z.string(), email: z.string(), url: z.string(), - workspace_id: z.string().nullable(), + org_id: z.string().nullable(), }) export type Account = z.infer @@ -17,12 +17,12 @@ export namespace Account { id: row.id, email: row.email, url: row.url, - workspace_id: row.workspace_id, + org_id: row.org_id, } } export function active(): Account | undefined { - const row = Database.use((db) => db.select().from(AccountTable).where(isNotNull(AccountTable.workspace_id)).get()) + const row = Database.use((db) => db.select().from(AccountTable).where(isNotNull(AccountTable.org_id)).get()) return row ? fromRow(row) : undefined } @@ -34,13 +34,13 @@ export namespace Account { Database.use((db) => db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()) } - export function use(accountID: string, workspaceID: string | null) { + export function use(accountID: string, orgID: string | null) { Database.use((db) => - db.update(AccountTable).set({ workspace_id: workspaceID }).where(eq(AccountTable.id, accountID)).run(), + db.update(AccountTable).set({ org_id: orgID }).where(eq(AccountTable.id, accountID)).run(), ) } - export async function workspaces(accountID: string): Promise<{ id: string; name: string }[]> { + export async function orgs(accountID: string): Promise<{ id: string; name: string }[]> { const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()) if (!row) return [] @@ -57,7 +57,7 @@ export namespace Account { return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" })) } - export async function config(accountID: string, workspaceID: string): Promise | undefined> { + export async function config(accountID: string, orgID: string): Promise | undefined> { const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()) if (!row) return undefined @@ -65,7 +65,7 @@ export namespace Account { if (!access) return undefined const res = await fetch(`${row.url}/api/config`, { - headers: { authorization: `Bearer ${access}`, "x-org-id": workspaceID }, + headers: { authorization: `Bearer ${access}`, "x-org-id": orgID }, }) if (!res.ok) return undefined @@ -192,15 +192,15 @@ export namespace Account { const expiry = Date.now() + json.expires_in! * 1000 const refresh = json.refresh_token ?? "" - // Fetch workspaces and get first one + // Fetch orgs and get first one const orgsRes = await fetch(`${input.server}/api/orgs`, { headers: { authorization: `Bearer ${access}` }, }) const orgs = (await orgsRes.json()) as Array<{ id?: string; name?: string }> - const firstWorkspaceId = orgs.length > 0 ? orgs[0].id : null + const firstOrgId = orgs.length > 0 ? orgs[0].id : null Database.use((db) => { - db.update(AccountTable).set({ workspace_id: null }).run() + db.update(AccountTable).set({ org_id: null }).run() db.insert(AccountTable) .values({ id, @@ -209,7 +209,7 @@ export namespace Account { access_token: access, refresh_token: refresh, token_expiry: expiry, - workspace_id: firstWorkspaceId, + org_id: firstOrgId, }) .onConflictDoUpdate({ target: AccountTable.id, @@ -217,7 +217,7 @@ export namespace Account { access_token: access, refresh_token: refresh, token_expiry: expiry, - workspace_id: firstWorkspaceId, + org_id: firstOrgId, }, }) .run() diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index ef1c64c8065..c5d85083f17 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -104,7 +104,7 @@ export const LogoutCommand = cmd({ export const SwitchCommand = cmd({ command: "switch", - describe: "switch active workspace", + describe: "switch active org", async handler() { UI.empty() @@ -114,35 +114,35 @@ export const SwitchCommand = cmd({ return } - const workspaces = await Account.workspaces(active.id) - if (workspaces.length === 0) { - UI.println("No workspaces found") + const orgs = await Account.orgs(active.id) + if (orgs.length === 0) { + UI.println("No orgs found") return } - prompts.intro("Switch workspace") + prompts.intro("Switch org") - const opts = workspaces.map((w) => ({ - value: w.id, - label: w.id === active.workspace_id ? w.name + UI.Style.TEXT_DIM + " (active)" : w.name, + const opts = orgs.map((o) => ({ + value: o.id, + label: o.id === active.org_id ? o.name + UI.Style.TEXT_DIM + " (active)" : o.name, })) const selected = await prompts.select({ - message: "Select workspace", + message: "Select org", options: opts, }) if (prompts.isCancel(selected)) return Account.use(active.id, selected as string) - prompts.outro("Switched to " + workspaces.find((w) => w.id === selected)?.name) + prompts.outro("Switched to " + orgs.find((o) => o.id === selected)?.name) }, }) -export const WorkspacesCommand = cmd({ - command: "workspaces", - aliases: ["workspace"], - describe: "list all workspaces", +export const OrgsCommand = cmd({ + command: "orgs", + aliases: ["org"], + describe: "list all orgs", async handler() { const accounts = Account.list() @@ -152,9 +152,9 @@ export const WorkspacesCommand = cmd({ } for (const account of accounts) { - const workspaces = await Account.workspaces(account.id) - for (const space of workspaces) { - UI.println([space.name, account.email, space.id].join("\t")) + const orgs = await Account.orgs(account.id) + for (const org of orgs) { + UI.println([org.name, account.email, org.id].join("\t")) } } }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 405bd8b9793..39aa77405f0 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -174,8 +174,8 @@ export namespace Config { } const active = Account.active() - if (active?.workspace_id) { - const config = await Account.config(active.id, active.workspace_id) + if (active?.org_id) { + const config = await Account.config(active.id, active.org_id) result = mergeConfigConcatArrays(result, config ?? {}) const token = await Account.token(active.id) // TODO: this is bad diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index e4b85e3902c..8af2f072660 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,7 +3,7 @@ import { hideBin } from "yargs/helpers" import { RunCommand } from "./cli/cmd/run" import { GenerateCommand } from "./cli/cmd/generate" import { Log } from "./util/log" -import { LoginCommand, LogoutCommand, SwitchCommand, WorkspacesCommand } from "./cli/cmd/account" +import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account" import { ProvidersCommand } from "./cli/cmd/providers" import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" @@ -133,7 +133,7 @@ let cli = yargs(hideBin(process.argv)) .command(LoginCommand) .command(LogoutCommand) .command(SwitchCommand) - .command(WorkspacesCommand) + .command(OrgsCommand) .command(ProvidersCommand) .command(AgentCommand) .command(UpgradeCommand) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index e8db405fddd..a57d1f80d1b 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -64,7 +64,7 @@ export namespace Session { id: row.id, slug: row.slug, projectID: row.project_id, - workspaceID: row.workspace_id ?? undefined, + orgID: row.org_id ?? undefined, directory: row.directory, parentID: row.parent_id ?? undefined, title: row.title, @@ -86,7 +86,7 @@ export namespace Session { return { id: info.id, project_id: info.projectID, - workspace_id: info.workspaceID, + org_id: info.orgID, parent_id: info.parentID, slug: info.slug, directory: info.directory, @@ -121,7 +121,7 @@ export namespace Session { id: Identifier.schema("session"), slug: z.string(), projectID: z.string(), - workspaceID: z.string().optional(), + orgID: z.string().optional(), directory: z.string(), parentID: Identifier.schema("session").optional(), summary: z @@ -301,7 +301,7 @@ export namespace Session { version: Installation.VERSION, projectID: Instance.project.id, directory: input.directory, - workspaceID: WorkspaceContext.workspaceID, + orgID: WorkspaceContext.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), permission: input.permission, @@ -532,7 +532,7 @@ export namespace Session { export function* list(input?: { directory?: string - workspaceID?: string + orgID?: string roots?: boolean start?: number search?: string @@ -542,7 +542,7 @@ export namespace Session { const conditions = [eq(SessionTable.project_id, project.id)] if (WorkspaceContext.workspaceID) { - conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID)) + conditions.push(eq(SessionTable.org_id, WorkspaceContext.workspaceID)) } if (input?.directory) { conditions.push(eq(SessionTable.directory, input.directory)) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 0630760f3bc..2573ad1cb5f 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -15,7 +15,7 @@ export const SessionTable = sqliteTable( project_id: text() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - workspace_id: text(), + org_id: text(), parent_id: text(), slug: text().notNull(), directory: text().notNull(), @@ -34,7 +34,7 @@ export const SessionTable = sqliteTable( }, (table) => [ index("session_project_idx").on(table.project_id), - index("session_workspace_idx").on(table.workspace_id), + index("session_org_idx").on(table.org_id), index("session_parent_idx").on(table.parent_id), ], ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 669883590df..ca1de76323a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -808,7 +808,7 @@ export type Session = { id: string slug: string projectID: string - workspaceID?: string + orgID?: string directory: string parentID?: string summary?: { @@ -1672,7 +1672,7 @@ export type GlobalSession = { id: string slug: string projectID: string - workspaceID?: string + orgID?: string directory: string parentID?: string summary?: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8e88b09691f..a1faa83837d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9046,7 +9046,7 @@ "projectID": { "type": "string" }, - "workspaceID": { + "orgID": { "type": "string" }, "directory": { @@ -11111,7 +11111,7 @@ "projectID": { "type": "string" }, - "workspaceID": { + "orgID": { "type": "string" }, "directory": { From b19dc933a40e7c770abb3f8f7ad46478bb773b46 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 5 Mar 2026 16:03:10 -0500 Subject: [PATCH 08/10] core: resolve account config env templates after loading control token --- packages/opencode/src/config/config.ts | 18 ++++++-- packages/opencode/test/config/config.test.ts | 47 ++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 39aa77405f0..758ce992345 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,6 +12,7 @@ import { lazy } from "../util/lazy" import { NamedError } from "@opencode-ai/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" +import { Env } from "../env" import { type ParseError as JsoncParseError, applyEdits, @@ -176,10 +177,21 @@ export namespace Config { const active = Account.active() if (active?.org_id) { const config = await Account.config(active.id, active.org_id) - result = mergeConfigConcatArrays(result, config ?? {}) const token = await Account.token(active.id) - // TODO: this is bad - process.env["OPENCODE_CONTROL_TOKEN"] = token + if (token) { + process.env["OPENCODE_CONTROL_TOKEN"] = token + Env.set("OPENCODE_CONTROL_TOKEN", token) + } + + if (config) { + result = mergeConfigConcatArrays( + result, + await load(JSON.stringify(config), { + dir: path.dirname(`${active.url}/api/config`), + source: `${active.url}/api/config`, + }), + ) + } } // Load managed config files last (highest priority) - enterprise admin-controlled diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f245dc3493d..ef54fee62dd 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" +import { Account } from "../../src/account" import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" @@ -197,6 +198,52 @@ test("preserves env variables when adding $schema to config", async () => { } }) +test("resolves env templates in account config with account token", async () => { + const originalActive = Account.active + const originalConfig = Account.config + const originalToken = Account.token + const originalControlToken = process.env["OPENCODE_CONTROL_TOKEN"] + + Account.active = mock(() => ({ + id: "account-1", + email: "user@example.com", + url: "https://control.example.com", + org_id: "org-1", + })) + + Account.config = mock(async () => ({ + provider: { + opencode: { + options: { + apiKey: "{env:OPENCODE_CONTROL_TOKEN}", + }, + }, + }, + })) + + Account.token = mock(async () => "st_test_token") + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") + }, + }) + } finally { + Account.active = originalActive + Account.config = originalConfig + Account.token = originalToken + if (originalControlToken !== undefined) { + process.env["OPENCODE_CONTROL_TOKEN"] = originalControlToken + } else { + delete process.env["OPENCODE_CONTROL_TOKEN"] + } + } +}) + test("handles file inclusion substitution", async () => { await using tmp = await tmpdir({ init: async (dir) => { From e9230472192f622c99b787f23dfe1762f862623b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 5 Mar 2026 16:04:43 -0500 Subject: [PATCH 09/10] core: route session sharing through org-scoped control APIs --- packages/opencode/src/cli/cmd/import.ts | 18 +++- .../src/cli/cmd/tui/routes/session/index.tsx | 14 ++- packages/opencode/src/share/share-next.ts | 88 +++++++++++++++---- .../opencode/test/share/share-next.test.ts | 76 ++++++++++++++++ 4 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/test/share/share-next.test.ts diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 4d65060f18f..dac1c10f8af 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -10,7 +10,7 @@ import { ShareNext } from "../../share/share-next" import { EOL } from "os" import { Filesystem } from "../../util/filesystem" -/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */ +/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ export type ShareData = | { type: "session"; data: SDKSession } | { type: "message"; data: Message } @@ -97,8 +97,20 @@ export const ImportCommand = cmd({ return } - const baseUrl = await ShareNext.url() - const response = await fetch(`${baseUrl}/api/share/${slug}/data`) + const parsed = new URL(args.file) + const baseUrl = parsed.origin + const req = await ShareNext.request() + + const dataPath = req.api.data(slug) + let response = await fetch(`${baseUrl}${dataPath}`, { + headers: req.headers, + }) + + if (!response.ok && dataPath !== `/api/share/${slug}/data`) { + response = await fetch(`${baseUrl}/api/share/${slug}/data`, { + headers: req.headers, + }) + } if (!response.ok) { process.stdout.write(`Failed to fetch share data: ${response.statusText}`) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 68f6796cddd..abe112e2685 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -378,7 +378,12 @@ export function Session() { sessionID: route.sessionID, }) .then((res) => copy(res.data!.share!.url)) - .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) + .catch((error) => { + toast.show({ + message: error instanceof Error ? error.message : "Failed to share session", + variant: "error", + }) + }) dialog.clear() }, }, @@ -481,7 +486,12 @@ export function Session() { sessionID: route.sessionID, }) .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) - .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" })) + .catch((error) => { + toast.show({ + message: error instanceof Error ? error.message : "Failed to unshare session", + variant: "error", + }) + }) dialog.clear() }, }, diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index c36616b7ef9..65c3b4d8620 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,4 +1,5 @@ import { Bus } from "@/bus" +import { Account } from "@/account" import { Config } from "@/config/config" import { ulid } from "ulid" import { Provider } from "@/provider/provider" @@ -12,8 +13,51 @@ import type * as SDK from "@opencode-ai/sdk/v2" export namespace ShareNext { const log = Log.create({ service: "share-next" }) + type ApiEndpoints = { + create: string + sync: (shareId: string) => string + remove: (shareId: string) => string + data: (shareId: string) => string + } + + function apiEndpoints(resource: string): ApiEndpoints { + return { + create: `/api/${resource}`, + sync: (shareId) => `/api/${resource}/${shareId}/sync`, + remove: (shareId) => `/api/${resource}/${shareId}`, + data: (shareId) => `/api/${resource}/${shareId}/data`, + } + } + + const legacyApi = apiEndpoints("share") + const controlApi = apiEndpoints("shares") + export async function url() { - return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") + const req = await request() + return req.baseUrl + } + + export async function request(): Promise<{ + headers: Record + api: ApiEndpoints + baseUrl: string + }> { + const headers: Record = {} + + const active = Account.active() + if (!active?.org_id) { + const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") + return { headers, api: legacyApi, baseUrl } + } + + const token = await Account.token(active.id) + if (!token) { + throw new Error("No active OpenControl token available for sharing") + } + + headers["authorization"] = `Bearer ${token}` + headers["x-org-id"] = active.org_id + return { headers, api: controlApi, baseUrl: active.url } } const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" @@ -69,15 +113,20 @@ export namespace ShareNext { export async function create(sessionID: string) { if (disabled) return { id: "", url: "", secret: "" } log.info("creating share", { sessionID }) - const result = await fetch(`${await url()}/api/share`, { + const req = await request() + const response = await fetch(`${req.baseUrl}${req.api.create}`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { ...req.headers, "Content-Type": "application/json" }, body: JSON.stringify({ sessionID: sessionID }), }) - .then((x) => x.json()) - .then((x) => x as { id: string; url: string; secret: string }) + + if (!response.ok) { + const message = await response.text().catch(() => response.statusText) + throw new Error(`Failed to create share (${response.status}): ${message || response.statusText}`) + } + + const result = (await response.json()) as { id: string; url: string; secret: string } + Database.use((db) => db .insert(SessionShareTable) @@ -145,16 +194,19 @@ export namespace ShareNext { const share = get(sessionID) if (!share) return - await fetch(`${await url()}/api/share/${share.id}/sync`, { + const req = await request() + const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { ...req.headers, "Content-Type": "application/json" }, body: JSON.stringify({ secret: share.secret, data: Array.from(queued.data.values()), }), }) + + if (!response.ok) { + log.warn("failed to sync share", { sessionID, shareID: share.id, status: response.status }) + } }, 1000) queue.set(sessionID, { timeout, data: dataMap }) } @@ -164,15 +216,21 @@ export namespace ShareNext { log.info("removing share", { sessionID }) const share = get(sessionID) if (!share) return - await fetch(`${await url()}/api/share/${share.id}`, { + + const req = await request() + const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, { method: "DELETE", - headers: { - "Content-Type": "application/json", - }, + headers: { ...req.headers, "Content-Type": "application/json" }, body: JSON.stringify({ secret: share.secret, }), }) + + if (!response.ok) { + const message = await response.text().catch(() => response.statusText) + throw new Error(`Failed to remove share (${response.status}): ${message || response.statusText}`) + } + Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) } diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts new file mode 100644 index 00000000000..f0855f37466 --- /dev/null +++ b/packages/opencode/test/share/share-next.test.ts @@ -0,0 +1,76 @@ +import { test, expect, mock } from "bun:test" +import { ShareNext } from "../../src/share/share-next" +import { Account } from "../../src/account" +import { Config } from "../../src/config/config" + +test("ShareNext.request uses legacy share API without active org account", async () => { + const originalActive = Account.active + const originalConfigGet = Config.get + + Account.active = mock(() => undefined) + Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } })) + + try { + const req = await ShareNext.request() + + expect(req.api.create).toBe("/api/share") + expect(req.api.sync("shr_123")).toBe("/api/share/shr_123/sync") + expect(req.api.remove("shr_123")).toBe("/api/share/shr_123") + expect(req.api.data("shr_123")).toBe("/api/share/shr_123/data") + expect(req.baseUrl).toBe("https://legacy-share.example.com") + expect(req.headers).toEqual({}) + } finally { + Account.active = originalActive + Config.get = originalConfigGet + } +}) + +test("ShareNext.request uses org share API with auth headers when account is active", async () => { + const originalActive = Account.active + const originalToken = Account.token + + Account.active = mock(() => ({ + id: "account-1", + email: "user@example.com", + url: "https://control.example.com", + org_id: "org-1", + })) + Account.token = mock(async () => "st_test_token") + + try { + const req = await ShareNext.request() + + expect(req.api.create).toBe("/api/shares") + expect(req.api.sync("shr_123")).toBe("/api/shares/shr_123/sync") + expect(req.api.remove("shr_123")).toBe("/api/shares/shr_123") + expect(req.api.data("shr_123")).toBe("/api/shares/shr_123/data") + expect(req.baseUrl).toBe("https://control.example.com") + expect(req.headers).toEqual({ + authorization: "Bearer st_test_token", + "x-org-id": "org-1", + }) + } finally { + Account.active = originalActive + Account.token = originalToken + } +}) + +test("ShareNext.request fails when org account has no token", async () => { + const originalActive = Account.active + const originalToken = Account.token + + Account.active = mock(() => ({ + id: "account-1", + email: "user@example.com", + url: "https://control.example.com", + org_id: "org-1", + })) + Account.token = mock(async () => undefined) + + try { + await expect(ShareNext.request()).rejects.toThrow("No active OpenControl token available for sharing") + } finally { + Account.active = originalActive + Account.token = originalToken + } +}) From fec8d5bcf14824af8353cbe8d49afc3232c23137 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 5 Mar 2026 16:56:36 -0500 Subject: [PATCH 10/10] core: prevent share auth headers from being sent cross-origin --- packages/opencode/src/cli/cmd/import.ts | 13 +++++++++++-- packages/opencode/test/cli/import.test.ts | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index dac1c10f8af..72898d60678 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -24,6 +24,14 @@ export function parseShareUrl(url: string): string | null { return match ? match[1] : null } +export function shouldAttachShareAuthHeaders(shareUrl: string, controlBaseUrl: string): boolean { + try { + return new URL(shareUrl).origin === new URL(controlBaseUrl).origin + } catch { + return false + } +} + /** * Transform ShareNext API response (flat array) into the nested structure for local file storage. * @@ -100,15 +108,16 @@ export const ImportCommand = cmd({ const parsed = new URL(args.file) const baseUrl = parsed.origin const req = await ShareNext.request() + const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} const dataPath = req.api.data(slug) let response = await fetch(`${baseUrl}${dataPath}`, { - headers: req.headers, + headers, }) if (!response.ok && dataPath !== `/api/share/${slug}/data`) { response = await fetch(`${baseUrl}/api/share/${slug}/data`, { - headers: req.headers, + headers, }) } diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts index a1a69dc0941..922c08114ba 100644 --- a/packages/opencode/test/cli/import.test.ts +++ b/packages/opencode/test/cli/import.test.ts @@ -1,5 +1,10 @@ import { test, expect } from "bun:test" -import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import" +import { + parseShareUrl, + shouldAttachShareAuthHeaders, + transformShareData, + type ShareData, +} from "../../src/cli/cmd/import" // parseShareUrl tests test("parses valid share URLs", () => { @@ -15,6 +20,19 @@ test("rejects invalid URLs", () => { expect(parseShareUrl("not-a-url")).toBeNull() }) +test("only attaches share auth headers for same-origin URLs", () => { + expect(shouldAttachShareAuthHeaders("https://control.example.com/share/abc", "https://control.example.com")).toBe( + true, + ) + expect( + shouldAttachShareAuthHeaders("https://other.example.com/share/abc", "https://control.example.com"), + ).toBe(false) + expect(shouldAttachShareAuthHeaders("https://control.example.com:443/share/abc", "https://control.example.com")).toBe( + true, + ) + expect(shouldAttachShareAuthHeaders("not-a-url", "https://control.example.com")).toBe(false) +}) + // transformShareData tests test("transforms share data to storage format", () => { const data: ShareData[] = [