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/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 new file mode 100644 index 00000000000..897c0f473e7 --- /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, + `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 new file mode 100644 index 00000000000..108641acc11 --- /dev/null +++ b/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json @@ -0,0 +1,1112 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "325559b7-104f-4d2a-a02c-934cfad7cfcc", + "prevIds": [ + "1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_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": "org_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": 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, + "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": "control_account" + }, + { + "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/control/control.sql.ts b/packages/opencode/src/account/account.sql.ts similarity index 61% rename from packages/opencode/src/control/control.sql.ts rename to packages/opencode/src/account/account.sql.ts index 7b805c16274..deeb8a4fcfc 100644 --- a/packages/opencode/src/control/control.sql.ts +++ b/packages/opencode/src/account/account.sql.ts @@ -1,7 +1,18 @@ -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 AccountTable = sqliteTable("account", { + id: text().primaryKey(), + email: text().notNull(), + url: text().notNull(), + access_token: text().notNull(), + refresh_token: text().notNull(), + token_expiry: integer(), + org_id: text(), + ...Timestamps, +}) + +// LEGACY export const ControlAccountTable = sqliteTable( "control_account", { diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts new file mode 100644 index 00000000000..3bb02c057a5 --- /dev/null +++ b/packages/opencode/src/account/index.ts @@ -0,0 +1,247 @@ +import { eq, sql, isNotNull } 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(), + org_id: z.string().nullable(), + }) + export type Account = z.infer + + function fromRow(row: (typeof AccountTable)["$inferSelect"]): Account { + return { + id: row.id, + email: row.email, + url: row.url, + org_id: row.org_id, + } + } + + export function active(): Account | undefined { + const row = Database.use((db) => db.select().from(AccountTable).where(isNotNull(AccountTable.org_id)).get()) + return row ? fromRow(row) : undefined + } + + export function list(): 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, orgID: string | null) { + Database.use((db) => + db.update(AccountTable).set({ org_id: orgID }).where(eq(AccountTable.id, accountID)).run(), + ) + } + + 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 [] + + 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 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 + + const access = await token(accountID) + if (!access) return undefined + + const res = await fetch(`${row.url}/api/config`, { + headers: { authorization: `Bearer ${access}`, "x-org-id": orgID }, + }) + + 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 + if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token + + const res = await fetch(`${row.url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: row.refresh_token, + }).toString(), + }) + + if (!res.ok) return + + const json = (await res.json()) as { + access_token: string + refresh_token?: string + expires_in?: number + } + + Database.use((db) => + db + .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(eq(AccountTable.id, row.id)) + .run(), + ) + + 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 + error?: string + error_description?: string + } + + if (json.access_token) { + 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 + const refresh = json.refresh_token ?? "" + + // 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 firstOrgId = orgs.length > 0 ? orgs[0].id : null + + Database.use((db) => { + db.update(AccountTable).set({ org_id: null }).run() + db.insert(AccountTable) + .values({ + id, + email, + url: input.server, + access_token: access, + refresh_token: refresh, + token_expiry: expiry, + org_id: firstOrgId, + }) + .onConflictDoUpdate({ + target: AccountTable.id, + set: { + access_token: access, + refresh_token: refresh, + token_expiry: expiry, + org_id: firstOrgId, + }, + }) + .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..c5d85083f17 --- /dev/null +++ b/packages/opencode/src/cli/cmd/account.ts @@ -0,0 +1,161 @@ +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 [email]", + describe: "log out from an account", + 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 org", + async handler() { + UI.empty() + + const active = Account.active() + if (!active) { + UI.println("Not logged in") + return + } + + const orgs = await Account.orgs(active.id) + if (orgs.length === 0) { + UI.println("No orgs found") + return + } + + prompts.intro("Switch org") + + 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 org", + options: opts, + }) + + if (prompts.isCancel(selected)) return + + Account.use(active.id, selected as string) + prompts.outro("Switched to " + orgs.find((o) => o.id === selected)?.name) + }, +}) + +export const OrgsCommand = cmd({ + command: "orgs", + aliases: ["org"], + describe: "list all orgs", + async handler() { + const accounts = Account.list() + + if (accounts.length === 0) { + UI.println("No accounts found") + return + } + + for (const account of accounts) { + 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/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 4d65060f18f..72898d60678 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 } @@ -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. * @@ -97,8 +105,21 @@ 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 headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} + + const dataPath = req.api.data(slug) + let response = await fetch(`${baseUrl}${dataPath}`, { + headers, + }) + + if (!response.ok && dataPath !== `/api/share/${slug}/data`) { + response = await fetch(`${baseUrl}/api/share/${slug}/data`, { + headers, + }) + } if (!response.ok) { process.stdout.write(`Failed to fetch share data: ${response.statusText}`) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts new file mode 100644 index 00000000000..499a6f0e360 --- /dev/null +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -0,0 +1,438 @@ +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 + +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] + + 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 +} + +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 ProvidersCommand = cmd({ + command: "providers", + aliases: ["auth"], + describe: "manage AI providers and credentials", + builder: (yargs) => + yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(), + async handler() {}, +}) + +export const ProvidersListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list providers and credentials", + async handler(_args) { + 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`) + + 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 ProvidersLoginCommand = 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() + + 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 ProvidersLogoutCommand = cmd({ + command: "logout", + describe: "log out from a configured provider", + async handler(_args) { + 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/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/config/config.ts b/packages/opencode/src/config/config.ts index 141f6156985..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, @@ -32,7 +33,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,10 +108,6 @@ export namespace Config { } } - const token = await Control.token() - if (token) { - } - // Global user config overrides remote config. result = mergeConfigConcatArrays(result, await global()) @@ -177,6 +174,26 @@ export namespace Config { log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } + const active = Account.active() + if (active?.org_id) { + const config = await Account.config(active.id, active.org_id) + const token = await Account.token(active.id) + 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 // Kept separate from directories array to avoid write operations when installing plugins // which would fail on system directories requiring elevated permissions diff --git a/packages/opencode/src/control/index.ts b/packages/opencode/src/control/index.ts deleted file mode 100644 index f712e88281f..00000000000 --- a/packages/opencode/src/control/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { eq, and } from "drizzle-orm" -import { Database } from "@/storage/db" -import { ControlAccountTable } from "./control.sql" -import z from "zod" - -export * from "./control.sql" - -export namespace Control { - export const Account = z.object({ - email: z.string(), - url: z.string(), - }) - export type Account = z.infer - - function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account { - return { - email: row.email, - url: row.url, - } - } - - export function account(): Account | undefined { - const row = Database.use((db) => - db.select().from(ControlAccountTable).where(eq(ControlAccountTable.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(), - ) - if (!row) return undefined - if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token - - const res = await fetch(`${row.url}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: row.refresh_token, - }).toString(), - }) - - if (!res.ok) return - - const json = (await res.json()) as { - access_token: string - refresh_token?: string - expires_in?: number - } - - Database.use((db) => - db - .update(ControlAccountTable) - .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))) - .run(), - ) - - return json.access_token - } -} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 35b42dce77c..8af2f072660 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -3,7 +3,8 @@ 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 { 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" import { UninstallCommand } from "./cli/cmd/uninstall" @@ -129,7 +130,11 @@ let cli = yargs(hideBin(process.argv)) .command(RunCommand) .command(GenerateCommand) .command(DebugCommand) - .command(AuthCommand) + .command(LoginCommand) + .command(LogoutCommand) + .command(SwitchCommand) + .command(OrgsCommand) + .command(ProvidersCommand) .command(AgentCommand) .command(UpgradeCommand) .command(UninstallCommand) 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/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/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" 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[] = [ 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/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) => { 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 + } +}) 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": { 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"],