summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax <[email protected]>2026-03-10 12:53:47 -0400
committerGitHub <[email protected]>2026-03-10 12:53:47 -0400
commit613562f5047aa0bad934da401146a04ed8adce84 (patch)
tree5f45b1f33ab6231c2eb9921c384a42de09e10077 /packages
parent9c4325bcf8070d84a6911ae78b898c116ebad2ac (diff)
downloadopencode-613562f5047aa0bad934da401146a04ed8adce84.tar.gz
opencode-613562f5047aa0bad934da401146a04ed8adce84.zip
core: make account login upgrades safe while adding multi-account workspace auth (#15487)
Co-authored-by: Kit Langton <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/migration/20260228203230_blue_harpoon/migration.sql17
-rw-r--r--packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json1102
-rw-r--r--packages/opencode/migration/20260309230000_move_org_to_state/migration.sql3
-rw-r--r--packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json1215
-rw-r--r--packages/opencode/package.json2
-rw-r--r--packages/opencode/src/account/account.sql.ts35
-rw-r--r--packages/opencode/src/account/index.ts43
-rw-r--r--packages/opencode/src/account/repo.ts138
-rw-r--r--packages/opencode/src/account/schema.ts73
-rw-r--r--packages/opencode/src/account/service.ts384
-rw-r--r--packages/opencode/src/cli/cmd/account.ts196
-rw-r--r--packages/opencode/src/cli/cmd/import.ts27
-rw-r--r--packages/opencode/src/cli/cmd/providers.ts (renamed from packages/opencode/src/cli/cmd/auth.ts)44
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx2
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx14
-rw-r--r--packages/opencode/src/cli/effect/prompt.ts25
-rw-r--r--packages/opencode/src/config/config.ts33
-rw-r--r--packages/opencode/src/control-plane/workspace.sql.ts2
-rw-r--r--packages/opencode/src/control/control.sql.ts22
-rw-r--r--packages/opencode/src/control/index.ts67
-rw-r--r--packages/opencode/src/effect/runtime.ts4
-rw-r--r--packages/opencode/src/index.ts9
-rw-r--r--packages/opencode/src/project/project.sql.ts2
-rw-r--r--packages/opencode/src/session/session.sql.ts6
-rw-r--r--packages/opencode/src/share/share-next.ts88
-rw-r--r--packages/opencode/src/share/share.sql.ts2
-rw-r--r--packages/opencode/src/storage/db.ts6
-rw-r--r--packages/opencode/src/storage/schema.ts4
-rw-r--r--packages/opencode/src/util/effect-http-client.ts11
-rw-r--r--packages/opencode/src/util/schema.ts17
-rw-r--r--packages/opencode/test/account/repo.test.ts338
-rw-r--r--packages/opencode/test/account/service.test.ts223
-rw-r--r--packages/opencode/test/cli/import.test.ts20
-rw-r--r--packages/opencode/test/cli/plugin-auth-picker.test.ts2
-rw-r--r--packages/opencode/test/config/config.test.ts47
-rw-r--r--packages/opencode/test/fixture/effect.ts7
-rw-r--r--packages/opencode/test/share/share-next.test.ts76
-rw-r--r--packages/opencode/tsconfig.json7
38 files changed, 4154 insertions, 159 deletions
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 000000000..85be58c88
--- /dev/null
+++ b/packages/opencode/migration/20260228203230_blue_harpoon/migration.sql
@@ -0,0 +1,17 @@
+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,
+ `selected_org_id` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `account_state` (
+ `id` integer PRIMARY KEY NOT NULL,
+ `active_account_id` text,
+ FOREIGN KEY (`active_account_id`) REFERENCES `account`(`id`) ON UPDATE no action ON DELETE set 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 000000000..80d9451ba
--- /dev/null
+++ b/packages/opencode/migration/20260228203230_blue_harpoon/snapshot.json
@@ -0,0 +1,1102 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "325559b7-104f-4d2a-a02c-934cfad7cfcc",
+ "prevIds": ["1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40"],
+ "ddl": [
+ {
+ "name": "account",
+ "entityType": "tables"
+ },
+ {
+ "name": "account_state",
+ "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": "selected_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": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active_account_id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "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": ["active_account_id"],
+ "tableTo": "account",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "SET NULL",
+ "nameExplicit": false,
+ "name": "fk_account_state_active_account_id_account_id_fk",
+ "entityType": "fks",
+ "table": "account_state"
+ },
+ {
+ "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": "account_state_pk",
+ "table": "account_state",
+ "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": []
+}
diff --git a/packages/opencode/migration/20260309230000_move_org_to_state/migration.sql b/packages/opencode/migration/20260309230000_move_org_to_state/migration.sql
new file mode 100644
index 000000000..4d1c7bccd
--- /dev/null
+++ b/packages/opencode/migration/20260309230000_move_org_to_state/migration.sql
@@ -0,0 +1,3 @@
+ALTER TABLE `account_state` ADD `active_org_id` text;--> statement-breakpoint
+UPDATE `account_state` SET `active_org_id` = (SELECT `selected_org_id` FROM `account` WHERE `account`.`id` = `account_state`.`active_account_id`);--> statement-breakpoint
+ALTER TABLE `account` DROP COLUMN `selected_org_id`;
diff --git a/packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json b/packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json
new file mode 100644
index 000000000..37b9eacac
--- /dev/null
+++ b/packages/opencode/migration/20260309230000_move_org_to_state/snapshot.json
@@ -0,0 +1,1215 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "fb311f30-9948-4131-b15c-7d308478a878",
+ "prevIds": [
+ "325559b7-104f-4d2a-a02c-934cfad7cfcc",
+ "4ec9de62-88a7-4bec-91cc-0a759e84db21"
+ ],
+ "ddl": [
+ {
+ "name": "account_state",
+ "entityType": "tables"
+ },
+ {
+ "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": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active_account_id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active_org_id",
+ "entityType": "columns",
+ "table": "account_state"
+ },
+ {
+ "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": "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": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "type",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "branch",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "extra",
+ "entityType": "columns",
+ "table": "workspace"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "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": "workspace_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": [
+ "active_account_id"
+ ],
+ "tableTo": "account",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "NO ACTION",
+ "onDelete": "SET NULL",
+ "nameExplicit": false,
+ "name": "fk_account_state_active_account_id_account_id_fk",
+ "entityType": "fks",
+ "table": "account_state"
+ },
+ {
+ "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_state_pk",
+ "table": "account_state",
+ "entityType": "pks"
+ },
+ {
+ "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": "workspace_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_workspace_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/package.json b/packages/opencode/package.json
index 1c24be8a1..fe991ad8a 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -27,6 +27,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
+ "@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -108,6 +109,7 @@
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
+ "effect": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
diff --git a/packages/opencode/src/account/account.sql.ts b/packages/opencode/src/account/account.sql.ts
new file mode 100644
index 000000000..e66b3c299
--- /dev/null
+++ b/packages/opencode/src/account/account.sql.ts
@@ -0,0 +1,35 @@
+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(),
+ ...Timestamps,
+})
+
+export const AccountStateTable = sqliteTable("account_state", {
+ id: integer().primaryKey(),
+ active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
+ active_org_id: text(),
+})
+
+// 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] })],
+)
diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts
new file mode 100644
index 000000000..b48ada1fb
--- /dev/null
+++ b/packages/opencode/src/account/index.ts
@@ -0,0 +1,43 @@
+import { Effect, Option, ServiceMap } from "effect"
+
+import {
+ Account as AccountSchema,
+ type AccountError,
+ type AccessToken,
+ AccountID,
+ AccountService,
+ OrgID,
+} from "./service"
+
+export { AccessToken, AccountID, OrgID } from "./service"
+
+import { runtime } from "@/effect/runtime"
+
+type AccountServiceShape = ServiceMap.Service.Shape<typeof AccountService>
+
+function runSync<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
+ return runtime.runSync(AccountService.use(f))
+}
+
+function runPromise<A>(f: (service: AccountServiceShape) => Effect.Effect<A, AccountError>) {
+ return runtime.runPromise(AccountService.use(f))
+}
+
+export namespace Account {
+ export const Account = AccountSchema
+ export type Account = AccountSchema
+
+ export function active(): Account | undefined {
+ return Option.getOrUndefined(runSync((service) => service.active()))
+ }
+
+ export async function config(accountID: AccountID, orgID: OrgID): Promise<Record<string, unknown> | undefined> {
+ const config = await runPromise((service) => service.config(accountID, orgID))
+ return Option.getOrUndefined(config)
+ }
+
+ export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
+ const token = await runPromise((service) => service.token(accountID))
+ return Option.getOrUndefined(token)
+ }
+}
diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts
new file mode 100644
index 000000000..ba71abe34
--- /dev/null
+++ b/packages/opencode/src/account/repo.ts
@@ -0,0 +1,138 @@
+import { eq } from "drizzle-orm"
+import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
+
+import { Database } from "@/storage/db"
+import { AccountStateTable, AccountTable } from "./account.sql"
+import { Account, AccountID, AccountRepoError, OrgID } from "./schema"
+
+export type AccountRow = (typeof AccountTable)["$inferSelect"]
+
+const decodeAccount = Schema.decodeUnknownSync(Account)
+
+type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
+
+const ACCOUNT_STATE_ID = 1
+
+const db = <A>(run: (db: DbClient) => A) =>
+ Effect.try({
+ try: () => Database.use(run),
+ catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
+ })
+
+const current = (db: DbClient) => {
+ const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
+ if (!state?.active_account_id) return
+ const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
+ if (!account) return
+ return { ...account, active_org_id: state.active_org_id ?? null }
+}
+
+const setState = (db: DbClient, accountID: AccountID, orgID: string | null) =>
+ db
+ .insert(AccountStateTable)
+ .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: orgID })
+ .onConflictDoUpdate({
+ target: AccountStateTable.id,
+ set: { active_account_id: accountID, active_org_id: orgID },
+ })
+ .run()
+
+export class AccountRepo extends ServiceMap.Service<
+ AccountRepo,
+ {
+ readonly active: () => Effect.Effect<Option.Option<Account>, AccountRepoError>
+ readonly list: () => Effect.Effect<Account[], AccountRepoError>
+ readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
+ readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
+ readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
+ readonly persistToken: (input: {
+ accountID: AccountID
+ accessToken: string
+ refreshToken: string
+ expiry: Option.Option<number>
+ }) => Effect.Effect<void, AccountRepoError>
+ readonly persistAccount: (input: {
+ id: AccountID
+ email: string
+ url: string
+ accessToken: string
+ refreshToken: string
+ expiry: number
+ orgID: Option.Option<OrgID>
+ }) => Effect.Effect<void, AccountRepoError>
+ }
+>()("@opencode/AccountRepo") {
+ static readonly layer: Layer.Layer<AccountRepo> = Layer.succeed(
+ AccountRepo,
+ AccountRepo.of({
+ active: Effect.fn("AccountRepo.active")(() =>
+ db((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decodeAccount(row)) : Option.none()))),
+ ),
+
+ list: Effect.fn("AccountRepo.list")(() => db((db) => db.select().from(AccountTable).all().map((row) => decodeAccount({ ...row, active_org_id: null })))),
+
+ remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
+ db((db) =>
+ Database.transaction((tx) => {
+ tx.update(AccountStateTable)
+ .set({ active_account_id: null, active_org_id: null })
+ .where(eq(AccountStateTable.active_account_id, accountID))
+ .run()
+ tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
+ }),
+ ).pipe(Effect.asVoid),
+ ),
+
+ use: Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
+ db((db) => setState(db, accountID, Option.getOrNull(orgID))).pipe(Effect.asVoid),
+ ),
+
+ getRow: Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
+ db((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
+ Effect.map(Option.fromNullishOr),
+ ),
+ ),
+
+ persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
+ db((db) =>
+ db
+ .update(AccountTable)
+ .set({
+ access_token: input.accessToken,
+ refresh_token: input.refreshToken,
+ token_expiry: Option.getOrNull(input.expiry),
+ })
+ .where(eq(AccountTable.id, input.accountID))
+ .run(),
+ ).pipe(Effect.asVoid),
+ ),
+
+ persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
+ const orgID = Option.getOrNull(input.orgID)
+ return db((db) =>
+ Database.transaction((tx) => {
+ tx.insert(AccountTable)
+ .values({
+ id: input.id,
+ email: input.email,
+ url: input.url,
+ access_token: input.accessToken,
+ refresh_token: input.refreshToken,
+ token_expiry: input.expiry,
+ })
+ .onConflictDoUpdate({
+ target: AccountTable.id,
+ set: {
+ access_token: input.accessToken,
+ refresh_token: input.refreshToken,
+ token_expiry: input.expiry,
+ },
+ })
+ .run()
+ setState(tx, input.id, orgID)
+ }),
+ ).pipe(Effect.asVoid)
+ }),
+ }),
+ )
+}
diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts
new file mode 100644
index 000000000..49c37932f
--- /dev/null
+++ b/packages/opencode/src/account/schema.ts
@@ -0,0 +1,73 @@
+import { Schema } from "effect"
+
+import { withStatics } from "@/util/schema"
+
+export const AccountID = Schema.String.pipe(
+ Schema.brand("AccountId"),
+ withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
+)
+export type AccountID = Schema.Schema.Type<typeof AccountID>
+
+export const OrgID = Schema.String.pipe(
+ Schema.brand("OrgId"),
+ withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
+)
+export type OrgID = Schema.Schema.Type<typeof OrgID>
+
+export const AccessToken = Schema.String.pipe(
+ Schema.brand("AccessToken"),
+ withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
+)
+export type AccessToken = Schema.Schema.Type<typeof AccessToken>
+
+export class Account extends Schema.Class<Account>("Account")({
+ id: AccountID,
+ email: Schema.String,
+ url: Schema.String,
+ active_org_id: Schema.NullOr(OrgID),
+}) {}
+
+export class Org extends Schema.Class<Org>("Org")({
+ id: OrgID,
+ name: Schema.String,
+}) {}
+
+export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
+ message: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
+
+export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
+ message: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+}) {}
+
+export type AccountError = AccountRepoError | AccountServiceError
+
+export class Login extends Schema.Class<Login>("Login")({
+ code: Schema.String,
+ user: Schema.String,
+ url: Schema.String,
+ server: Schema.String,
+ expiry: Schema.Number,
+ interval: Schema.Number,
+}) {}
+
+export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
+ email: Schema.String,
+}) {}
+
+export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
+
+export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
+
+export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
+
+export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
+
+export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
+ cause: Schema.Defect,
+}) {}
+
+export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
+export type PollResult = Schema.Schema.Type<typeof PollResult>
diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/service.ts
new file mode 100644
index 000000000..68ea9dbde
--- /dev/null
+++ b/packages/opencode/src/account/service.ts
@@ -0,0 +1,384 @@
+import { Clock, Effect, Layer, Option, Schema, ServiceMap } from "effect"
+import {
+ FetchHttpClient,
+ HttpClient,
+ HttpClientError,
+ HttpClientRequest,
+ HttpClientResponse,
+} from "effect/unstable/http"
+
+import { withTransientReadRetry } from "@/util/effect-http-client"
+import { AccountRepo, type AccountRow } from "./repo"
+import {
+ type AccountError,
+ AccessToken,
+ Account,
+ AccountID,
+ AccountServiceError,
+ Login,
+ Org,
+ OrgID,
+ PollDenied,
+ PollError,
+ PollExpired,
+ PollPending,
+ type PollResult,
+ PollSlow,
+ PollSuccess,
+} from "./schema"
+
+export * from "./schema"
+
+export type AccountOrgs = {
+ account: Account
+ orgs: Org[]
+}
+
+const RemoteOrg = Schema.Struct({
+ id: Schema.optional(OrgID),
+ name: Schema.optional(Schema.String),
+})
+
+const RemoteOrgs = Schema.Array(RemoteOrg)
+
+const RemoteConfig = Schema.Struct({
+ config: Schema.Record(Schema.String, Schema.Json),
+})
+
+const TokenRefresh = Schema.Struct({
+ access_token: Schema.String,
+ refresh_token: Schema.optional(Schema.String),
+ expires_in: Schema.optional(Schema.Number),
+})
+
+const DeviceCode = Schema.Struct({
+ device_code: Schema.String,
+ user_code: Schema.String,
+ verification_uri_complete: Schema.String,
+ expires_in: Schema.Number,
+ interval: Schema.Number,
+})
+
+const DeviceToken = Schema.Struct({
+ access_token: Schema.optional(Schema.String),
+ refresh_token: Schema.optional(Schema.String),
+ expires_in: Schema.optional(Schema.Number),
+ error: Schema.optional(Schema.String),
+ error_description: Schema.optional(Schema.String),
+})
+
+const User = Schema.Struct({
+ id: Schema.optional(AccountID),
+ email: Schema.optional(Schema.String),
+})
+
+const ClientId = Schema.Struct({ client_id: Schema.String })
+
+const DeviceTokenRequest = Schema.Struct({
+ grant_type: Schema.String,
+ device_code: Schema.String,
+ client_id: Schema.String,
+})
+
+const clientId = "opencode-cli"
+
+const toAccountServiceError = (message: string, cause?: unknown) => new AccountServiceError({ message, cause })
+
+const mapAccountServiceError =
+ (operation: string, message = "Account service operation failed") =>
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
+ effect.pipe(
+ Effect.mapError((error) =>
+ error instanceof AccountServiceError ? error : toAccountServiceError(`${message} (${operation})`, error),
+ ),
+ )
+
+export class AccountService extends ServiceMap.Service<
+ AccountService,
+ {
+ readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
+ readonly list: () => Effect.Effect<Account[], AccountError>
+ readonly orgsByAccount: () => Effect.Effect<AccountOrgs[], AccountError>
+ readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
+ readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>
+ readonly orgs: (accountID: AccountID) => Effect.Effect<Org[], AccountError>
+ readonly config: (
+ accountID: AccountID,
+ orgID: OrgID,
+ ) => Effect.Effect<Option.Option<Record<string, unknown>>, AccountError>
+ readonly token: (accountID: AccountID) => Effect.Effect<Option.Option<AccessToken>, AccountError>
+ readonly login: (url: string) => Effect.Effect<Login, AccountError>
+ readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
+ }
+>()("@opencode/Account") {
+ static readonly layer: Layer.Layer<AccountService, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
+ AccountService,
+ Effect.gen(function* () {
+ const repo = yield* AccountRepo
+ const http = yield* HttpClient.HttpClient
+ const httpRead = withTransientReadRetry(http)
+
+ const execute = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
+ http.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
+
+ const executeRead = (operation: string, request: HttpClientRequest.HttpClientRequest) =>
+ httpRead.execute(request).pipe(mapAccountServiceError(operation, "HTTP request failed"))
+
+ const executeEffect = <E>(operation: string, request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
+ request.pipe(
+ Effect.flatMap((req) => http.execute(req)),
+ mapAccountServiceError(operation, "HTTP request failed"),
+ )
+
+ const okOrNone = (operation: string, response: HttpClientResponse.HttpClientResponse) =>
+ HttpClientResponse.filterStatusOk(response).pipe(
+ Effect.map(Option.some),
+ Effect.catch((error) =>
+ HttpClientError.isHttpClientError(error) && error.reason._tag === "StatusCodeError"
+ ? Effect.succeed(Option.none<HttpClientResponse.HttpClientResponse>())
+ : Effect.fail(error),
+ ),
+ mapAccountServiceError(operation),
+ )
+
+ const tokenForRow = Effect.fn("AccountService.tokenForRow")(function* (found: AccountRow) {
+ const now = yield* Clock.currentTimeMillis
+ if (found.token_expiry && found.token_expiry > now) return Option.some(AccessToken.make(found.access_token))
+
+ const response = yield* execute(
+ "token.refresh",
+ HttpClientRequest.post(`${found.url}/oauth/token`).pipe(
+ HttpClientRequest.acceptJson,
+ HttpClientRequest.bodyUrlParams({
+ grant_type: "refresh_token",
+ refresh_token: found.refresh_token,
+ }),
+ ),
+ )
+
+ const ok = yield* okOrNone("token.refresh", response)
+ if (Option.isNone(ok)) return Option.none()
+
+ const parsed = yield* HttpClientResponse.schemaBodyJson(TokenRefresh)(ok.value).pipe(
+ mapAccountServiceError("token.refresh", "Failed to decode response"),
+ )
+
+ const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
+
+ yield* repo.persistToken({
+ accountID: AccountID.make(found.id),
+ accessToken: parsed.access_token,
+ refreshToken: parsed.refresh_token ?? found.refresh_token,
+ expiry,
+ })
+
+ return Option.some(AccessToken.make(parsed.access_token))
+ })
+
+ const resolveAccess = Effect.fn("AccountService.resolveAccess")(function* (accountID: AccountID) {
+ const maybeAccount = yield* repo.getRow(accountID)
+ if (Option.isNone(maybeAccount)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
+
+ const account = maybeAccount.value
+ const accessToken = yield* tokenForRow(account)
+ if (Option.isNone(accessToken)) return Option.none<{ account: AccountRow; accessToken: AccessToken }>()
+
+ return Option.some({ account, accessToken: accessToken.value })
+ })
+
+ const token = Effect.fn("AccountService.token")((accountID: AccountID) =>
+ resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))),
+ )
+
+ const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () {
+ const accounts = yield* repo.list()
+ return yield* Effect.forEach(
+ accounts,
+ (account) => orgs(account.id).pipe(Effect.map((orgs) => ({ account, orgs }))),
+ { concurrency: 3 },
+ )
+ })
+
+ const orgs = Effect.fn("AccountService.orgs")(function* (accountID: AccountID) {
+ const resolved = yield* resolveAccess(accountID)
+ if (Option.isNone(resolved)) return []
+
+ const { account, accessToken } = resolved.value
+
+ const response = yield* executeRead(
+ "orgs",
+ HttpClientRequest.get(`${account.url}/api/orgs`).pipe(
+ HttpClientRequest.acceptJson,
+ HttpClientRequest.bearerToken(accessToken),
+ ),
+ )
+
+ const ok = yield* okOrNone("orgs", response)
+ if (Option.isNone(ok)) return []
+
+ const orgs = yield* HttpClientResponse.schemaBodyJson(RemoteOrgs)(ok.value).pipe(
+ mapAccountServiceError("orgs", "Failed to decode response"),
+ )
+ return orgs
+ .filter((org) => org.id !== undefined && org.name !== undefined)
+ .map((org) => new Org({ id: org.id!, name: org.name! }))
+ })
+
+ const config = Effect.fn("AccountService.config")(function* (accountID: AccountID, orgID: OrgID) {
+ const resolved = yield* resolveAccess(accountID)
+ if (Option.isNone(resolved)) return Option.none()
+
+ const { account, accessToken } = resolved.value
+
+ const response = yield* executeRead(
+ "config",
+ HttpClientRequest.get(`${account.url}/api/config`).pipe(
+ HttpClientRequest.acceptJson,
+ HttpClientRequest.bearerToken(accessToken),
+ HttpClientRequest.setHeaders({ "x-org-id": orgID }),
+ ),
+ )
+
+ const ok = yield* okOrNone("config", response)
+ if (Option.isNone(ok)) return Option.none()
+
+ const parsed = yield* HttpClientResponse.schemaBodyJson(RemoteConfig)(ok.value).pipe(
+ mapAccountServiceError("config", "Failed to decode response"),
+ )
+ return Option.some(parsed.config)
+ })
+
+ const login = Effect.fn("AccountService.login")(function* (server: string) {
+ const response = yield* executeEffect(
+ "login",
+ HttpClientRequest.post(`${server}/auth/device/code`).pipe(
+ HttpClientRequest.acceptJson,
+ HttpClientRequest.schemaBodyJson(ClientId)({ client_id: clientId }),
+ ),
+ )
+
+ const ok = yield* okOrNone("login", response)
+ if (Option.isNone(ok)) {
+ const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
+ return yield* toAccountServiceError(`Failed to initiate device flow: ${body || response.status}`)
+ }
+
+ const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceCode)(ok.value).pipe(
+ mapAccountServiceError("login", "Failed to decode response"),
+ )
+ return new Login({
+ code: parsed.device_code,
+ user: parsed.user_code,
+ url: `${server}${parsed.verification_uri_complete}`,
+ server,
+ expiry: parsed.expires_in,
+ interval: parsed.interval,
+ })
+ })
+
+ const poll = Effect.fn("AccountService.poll")(function* (input: Login) {
+ const response = yield* executeEffect(
+ "poll",
+ HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
+ HttpClientRequest.acceptJson,
+ HttpClientRequest.schemaBodyJson(DeviceTokenRequest)({
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+ device_code: input.code,
+ client_id: clientId,
+ }),
+ ),
+ )
+
+ const parsed = yield* HttpClientResponse.schemaBodyJson(DeviceToken)(response).pipe(
+ mapAccountServiceError("poll", "Failed to decode response"),
+ )
+
+ if (!parsed.access_token) {
+ if (parsed.error === "authorization_pending") return new PollPending()
+ if (parsed.error === "slow_down") return new PollSlow()
+ if (parsed.error === "expired_token") return new PollExpired()
+ if (parsed.error === "access_denied") return new PollDenied()
+ return new PollError({ cause: parsed.error })
+ }
+
+ const access = parsed.access_token
+
+ const fetchUser = executeRead(
+ "poll.user",
+ HttpClientRequest.get(`${input.server}/api/user`).pipe(
+ HttpClientRequest.acceptJson,
+ HttpClientRequest.bearerToken(access),
+ ),
+ ).pipe(
+ Effect.flatMap((r) =>
+ HttpClientResponse.schemaBodyJson(User)(r).pipe(
+ mapAccountServiceError("poll.user", "Failed to decode response"),
+ ),
+ ),
+ )
+
+ const fetchOrgs = executeRead(
+ "poll.orgs",
+ HttpClientRequest.get(`${input.server}/api/orgs`).pipe(
+ HttpClientRequest.acceptJson,
+ HttpClientRequest.bearerToken(access),
+ ),
+ ).pipe(
+ Effect.flatMap((r) =>
+ HttpClientResponse.schemaBodyJson(RemoteOrgs)(r).pipe(
+ mapAccountServiceError("poll.orgs", "Failed to decode response"),
+ ),
+ ),
+ )
+
+ const [user, remoteOrgs] = yield* Effect.all([fetchUser, fetchOrgs], { concurrency: 2 })
+
+ const userId = user.id
+ const userEmail = user.email
+
+ if (!userId || !userEmail) {
+ return new PollError({ cause: "No id or email in response" })
+ }
+
+ const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
+
+ const now = yield* Clock.currentTimeMillis
+ const expiry = now + (parsed.expires_in ?? 0) * 1000
+ const refresh = parsed.refresh_token ?? ""
+ if (!refresh) {
+ yield* Effect.logWarning("Server did not return a refresh token — session may expire without ability to refresh")
+ }
+
+ yield* repo.persistAccount({
+ id: userId,
+ email: userEmail,
+ url: input.server,
+ accessToken: access,
+ refreshToken: refresh,
+ expiry,
+ orgID: firstOrgID,
+ })
+
+ return new PollSuccess({ email: userEmail })
+ })
+
+ return AccountService.of({
+ active: repo.active,
+ list: repo.list,
+ orgsByAccount,
+ remove: repo.remove,
+ use: repo.use,
+ orgs,
+ config,
+ token,
+ login,
+ poll,
+ })
+ }),
+ )
+
+ static readonly defaultLayer = AccountService.layer.pipe(
+ Layer.provide(AccountRepo.layer),
+ Layer.provide(FetchHttpClient.layer),
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts
new file mode 100644
index 000000000..51cf2b138
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/account.ts
@@ -0,0 +1,196 @@
+import { cmd } from "./cmd"
+import { Duration, Effect, Match, Option } from "effect"
+import { UI } from "../ui"
+import { runtime } from "@/effect/runtime"
+import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service"
+import { type AccountError } from "@/account/schema"
+import * as Prompt from "../effect/prompt"
+import open from "open"
+
+const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
+
+const println = (msg: string) => Effect.sync(() => UI.println(msg))
+
+const loginEffect = Effect.fn("login")(function* (url: string) {
+ const service = yield* AccountService
+
+ yield* Prompt.intro("Log in")
+ const login = yield* service.login(url)
+
+ yield* Prompt.log.info("Go to: " + login.url)
+ yield* Prompt.log.info("Enter code: " + login.user)
+ yield* openBrowser(login.url)
+
+ const s = Prompt.spinner()
+ yield* s.start("Waiting for authorization...")
+
+ const poll = (wait: number): Effect.Effect<PollResult, AccountError> =>
+ Effect.gen(function* () {
+ yield* Effect.sleep(wait)
+ const result = yield* service.poll(login)
+ if (result._tag === "PollPending") return yield* poll(wait)
+ if (result._tag === "PollSlow") return yield* poll(wait + 5000)
+ return result
+ })
+
+ const result = yield* poll(login.interval * 1000).pipe(
+ Effect.timeout(Duration.seconds(login.expiry)),
+ Effect.catchTag("TimeoutError", () => Effect.succeed(new PollExpired())),
+ )
+
+ yield* Match.valueTags(result, {
+ PollSuccess: (r) =>
+ Effect.gen(function* () {
+ yield* s.stop("Logged in as " + r.email)
+ yield* Prompt.outro("Done")
+ }),
+ PollExpired: () => s.stop("Device code expired", 1),
+ PollDenied: () => s.stop("Authorization denied", 1),
+ PollError: (r) => s.stop("Error: " + String(r.cause), 1),
+ PollPending: () => s.stop("Unexpected state", 1),
+ PollSlow: () => s.stop("Unexpected state", 1),
+ })
+})
+
+const logoutEffect = Effect.fn("logout")(function* (email?: string) {
+ const service = yield* AccountService
+ const accounts = yield* service.list()
+ if (accounts.length === 0) return yield* println("Not logged in")
+
+ if (email) {
+ const match = accounts.find((a) => a.email === email)
+ if (!match) return yield* println("Account not found: " + email)
+ yield* service.remove(match.id)
+ yield* Prompt.outro("Logged out from " + email)
+ return
+ }
+
+ const active = yield* service.active()
+ const activeID = Option.map(active, (a) => a.id)
+
+ yield* Prompt.intro("Log out")
+
+ const opts = accounts.map((a) => {
+ const isActive = Option.isSome(activeID) && activeID.value === a.id
+ const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
+ return {
+ value: a,
+ label: isActive
+ ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)"
+ : `${a.email} ${server}`,
+ }
+ })
+
+ const selected = yield* Prompt.select({ message: "Select account to log out", options: opts })
+ if (Option.isNone(selected)) return
+
+ yield* service.remove(selected.value.id)
+ yield* Prompt.outro("Logged out from " + selected.value.email)
+})
+
+interface OrgChoice {
+ orgID: OrgID
+ accountID: AccountID
+ label: string
+}
+
+const switchEffect = Effect.fn("switch")(function* () {
+ const service = yield* AccountService
+
+ const groups = yield* service.orgsByAccount()
+ if (groups.length === 0) return yield* println("Not logged in")
+
+ const active = yield* service.active()
+ const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
+
+ const opts = groups.flatMap((group) =>
+ group.orgs.map((org) => {
+ const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
+ return {
+ value: { orgID: org.id, accountID: group.account.id, label: org.name },
+ label: isActive
+ ? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
+ : `${org.name} (${group.account.email})`,
+ }
+ }),
+ )
+ if (opts.length === 0) return yield* println("No orgs found")
+
+ yield* Prompt.intro("Switch org")
+
+ const selected = yield* Prompt.select<OrgChoice>({ message: "Select org", options: opts })
+ if (Option.isNone(selected)) return
+
+ const choice = selected.value
+ yield* service.use(choice.accountID, Option.some(choice.orgID))
+ yield* Prompt.outro("Switched to " + choice.label)
+})
+
+const orgsEffect = Effect.fn("orgs")(function* () {
+ const service = yield* AccountService
+
+ const groups = yield* service.orgsByAccount()
+ if (groups.length === 0) return yield* println("No accounts found")
+ if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
+
+ const active = yield* service.active()
+ const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
+
+ for (const group of groups) {
+ for (const org of group.orgs) {
+ const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
+ const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
+ const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
+ const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
+ const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
+ yield* println(` ${dot} ${name} ${email} ${id}`)
+ }
+ }
+})
+
+export const LoginCommand = cmd({
+ command: "login <url>",
+ describe: false,
+ builder: (yargs) =>
+ yargs.positional("url", {
+ describe: "server URL",
+ type: "string",
+ demandOption: true,
+ }),
+ async handler(args) {
+ UI.empty()
+ await runtime.runPromise(loginEffect(args.url))
+ },
+})
+
+export const LogoutCommand = cmd({
+ command: "logout [email]",
+ describe: false,
+ builder: (yargs) =>
+ yargs.positional("email", {
+ describe: "account email to log out from",
+ type: "string",
+ }),
+ async handler(args) {
+ UI.empty()
+ await runtime.runPromise(logoutEffect(args.email))
+ },
+})
+
+export const SwitchCommand = cmd({
+ command: "switch",
+ describe: false,
+ async handler() {
+ UI.empty()
+ await runtime.runPromise(switchEffect())
+ },
+})
+
+export const OrgsCommand = cmd({
+ command: "orgs",
+ describe: false,
+ async handler() {
+ UI.empty()
+ await runtime.runPromise(orgsEffect())
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 58c192825..eb5964379 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, accountBaseUrl: string): boolean {
+ try {
+ return new URL(shareUrl).origin === new URL(accountBaseUrl).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/auth.ts b/packages/opencode/src/cli/cmd/providers.ts
index 38fba0ce7..53a9b1280 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/packages/opencode/src/cli/cmd/providers.ts
@@ -13,14 +13,9 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
-import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
-/**
- * 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, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
@@ -33,7 +28,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
index = match
} else if (plugin.auth.methods.length > 1) {
- const selected = await prompts.select({
+ const method = await prompts.select({
message: "Login method",
options: [
...plugin.auth.methods.map((x, index) => ({
@@ -42,13 +37,12 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})),
],
})
- if (prompts.isCancel(selected)) throw new UI.CancelledError()
- index = parseInt(selected)
+ if (prompts.isCancel(method)) throw new UI.CancelledError()
+ index = parseInt(method)
}
const method = plugin.auth.methods[index]
- // Handle prompts for all auth types
- await sleep(10)
+ await new Promise((r) => setTimeout(r, 10))
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -171,11 +165,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<string, unknown>
@@ -203,19 +192,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()
@@ -231,7 +221,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)) {
@@ -258,7 +247,7 @@ export const AuthListCommand = cmd({
},
})
-export const AuthLoginCommand = cmd({
+export const ProvidersLoginCommand = cmd({
command: "login [url]",
describe: "log in to a provider",
builder: (yargs) =>
@@ -356,7 +345,7 @@ export const AuthLoginCommand = cmd({
value: x.id,
hint: {
opencode: "recommended",
- anthropic: "Claude Max or API key",
+ anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
@@ -409,7 +398,6 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
- // 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, args.method)
@@ -461,10 +449,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/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
index 7bf189f09..f77e4727a 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx
@@ -36,7 +36,7 @@ export function createDialogProviderOptions() {
value: provider.id,
description: {
opencode: "(Recommended)",
- anthropic: "(Claude Max or API key)",
+ anthropic: "(API key)",
openai: "(ChatGPT Plus/Pro or API key)",
"opencode-go": "Low cost subscription for everyone",
}[provider.id],
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 5358b61ef..7456742cd 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -383,7 +383,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()
},
},
@@ -486,7 +491,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/cli/effect/prompt.ts b/packages/opencode/src/cli/effect/prompt.ts
new file mode 100644
index 000000000..7f9cd8cfe
--- /dev/null
+++ b/packages/opencode/src/cli/effect/prompt.ts
@@ -0,0 +1,25 @@
+import * as prompts from "@clack/prompts"
+import { Effect, Option } from "effect"
+
+export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
+export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
+
+export const log = {
+ info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
+}
+
+export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
+ Effect.tryPromise(() => prompts.select(opts)).pipe(
+ Effect.map((result) => {
+ if (prompts.isCancel(result)) return Option.none<Value>()
+ return Option.some(result)
+ }),
+ )
+
+export const spinner = () => {
+ const s = prompts.spinner()
+ return {
+ start: (msg: string) => Effect.sync(() => s.start(msg)),
+ stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
+ }
+}
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index ef2821727..2b8aa9e03 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"
@@ -108,10 +109,6 @@ export namespace Config {
}
}
- const token = await Control.token()
- if (token) {
- }
-
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
@@ -178,6 +175,32 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
+ const active = Account.active()
+ if (active?.active_org_id) {
+ try {
+ const [config, token] = await Promise.all([
+ Account.config(active.id, active.active_org_id),
+ Account.token(active.id),
+ ])
+ if (token) {
+ process.env["OPENCODE_CONSOLE_TOKEN"] = token
+ Env.set("OPENCODE_CONSOLE_TOKEN", token)
+ }
+
+ if (config) {
+ result = mergeConfigConcatArrays(
+ result,
+ await load(JSON.stringify(config), {
+ dir: path.dirname(`${active.url}/api/config`),
+ source: `${active.url}/api/config`,
+ }),
+ )
+ }
+ } catch (err: any) {
+ log.debug("failed to fetch remote account config", { error: err?.message ?? err })
+ }
+ }
+
// 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-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts
index 1ba1605f8..763962069 100644
--- a/packages/opencode/src/control-plane/workspace.sql.ts
+++ b/packages/opencode/src/control-plane/workspace.sql.ts
@@ -1,5 +1,5 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
-import { ProjectTable } from "@/project/project.sql"
+import { ProjectTable } from "../project/project.sql"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().primaryKey(),
diff --git a/packages/opencode/src/control/control.sql.ts b/packages/opencode/src/control/control.sql.ts
deleted file mode 100644
index 7b805c162..000000000
--- a/packages/opencode/src/control/control.sql.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
-import { eq } from "drizzle-orm"
-import { Timestamps } from "@/storage/schema.sql"
-
-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)),
- ],
-)
diff --git a/packages/opencode/src/control/index.ts b/packages/opencode/src/control/index.ts
deleted file mode 100644
index f712e8828..000000000
--- 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<typeof Account>
-
- 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<string | undefined> {
- 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/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
new file mode 100644
index 000000000..1868b38a0
--- /dev/null
+++ b/packages/opencode/src/effect/runtime.ts
@@ -0,0 +1,4 @@
+import { ManagedRuntime } from "effect"
+import { AccountService } from "@/account/service"
+
+export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 4fd5f0e67..10ed8530a 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"
@@ -134,7 +135,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/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts
index 12373244f..7f0f8ca53 100644
--- a/packages/opencode/src/project/project.sql.ts
+++ b/packages/opencode/src/project/project.sql.ts
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
-import { Timestamps } from "@/storage/schema.sql"
+import { Timestamps } from "../storage/schema.sql"
export const ProjectTable = sqliteTable("project", {
id: text().primaryKey(),
diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts
index 0630760f3..b3228f400 100644
--- a/packages/opencode/src/session/session.sql.ts
+++ b/packages/opencode/src/session/session.sql.ts
@@ -1,9 +1,9 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2"
-import type { Snapshot } from "@/snapshot"
-import type { PermissionNext } from "@/permission/next"
-import { Timestamps } from "@/storage/schema.sql"
+import type { Snapshot } from "../snapshot"
+import type { PermissionNext } from "../permission/next"
+import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID">
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index 544376278..73fd21d90 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 { Provider } from "@/provider/provider"
import { Session } from "@/session"
@@ -11,8 +12,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 consoleApi = 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<string, string>
+ api: ApiEndpoints
+ baseUrl: string
+ }> {
+ const headers: Record<string, string> = {}
+
+ const active = Account.active()
+ if (!active?.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 account token available for sharing")
+ }
+
+ headers["authorization"] = `Bearer ${token}`
+ headers["x-org-id"] = active.active_org_id
+ return { headers, api: consoleApi, baseUrl: active.url }
}
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
@@ -68,15 +112,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)
@@ -159,16 +208,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 })
}
@@ -178,15 +230,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/share/share.sql.ts b/packages/opencode/src/share/share.sql.ts
index 268d41a6f..f337e106a 100644
--- a/packages/opencode/src/share/share.sql.ts
+++ b/packages/opencode/src/share/share.sql.ts
@@ -1,6 +1,6 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { SessionTable } from "../session/session.sql"
-import { Timestamps } from "@/storage/schema.sql"
+import { Timestamps } from "../storage/schema.sql"
export const SessionShareTable = sqliteTable("session_share", {
session_id: text()
diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts
index cd920a890..beb8e3eb5 100644
--- a/packages/opencode/src/storage/db.ts
+++ b/packages/opencode/src/storage/db.ts
@@ -39,7 +39,7 @@ export namespace Database {
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
- type Client = SQLiteBunDatabase<Schema>
+ type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[]
@@ -93,7 +93,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 =
@@ -124,7 +124,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
index 4c1c2490e..0c12cee62 100644
--- a/packages/opencode/src/storage/schema.ts
+++ b/packages/opencode/src/storage/schema.ts
@@ -1,5 +1,5 @@
-export { ControlAccountTable } from "../control/control.sql"
+export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql"
+export { ProjectTable } from "../project/project.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/src/util/effect-http-client.ts b/packages/opencode/src/util/effect-http-client.ts
new file mode 100644
index 000000000..0c95e34b5
--- /dev/null
+++ b/packages/opencode/src/util/effect-http-client.ts
@@ -0,0 +1,11 @@
+import { Schedule } from "effect"
+import { HttpClient } from "effect/unstable/http"
+
+export const withTransientReadRetry = <E, R>(client: HttpClient.HttpClient.With<E, R>) =>
+ client.pipe(
+ HttpClient.retryTransient({
+ retryOn: "errors-and-responses",
+ times: 2,
+ schedule: Schedule.exponential(200).pipe(Schedule.jittered),
+ }),
+ )
diff --git a/packages/opencode/src/util/schema.ts b/packages/opencode/src/util/schema.ts
new file mode 100644
index 000000000..180f952d7
--- /dev/null
+++ b/packages/opencode/src/util/schema.ts
@@ -0,0 +1,17 @@
+import { Schema } from "effect"
+
+/**
+ * Attach static methods to a schema object. Designed to be used with `.pipe()`:
+ *
+ * @example
+ * export const Foo = fooSchema.pipe(
+ * withStatics((schema) => ({
+ * zero: schema.makeUnsafe(0),
+ * from: Schema.decodeUnknownOption(schema),
+ * }))
+ * )
+ */
+export const withStatics =
+ <S extends object, M extends Record<string, unknown>>(methods: (schema: S) => M) =>
+ (schema: S): S & M =>
+ Object.assign(schema, methods(schema))
diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts
new file mode 100644
index 000000000..ecc392ead
--- /dev/null
+++ b/packages/opencode/test/account/repo.test.ts
@@ -0,0 +1,338 @@
+import { expect } from "bun:test"
+import { Effect, Layer, Option } from "effect"
+
+import { AccountRepo } from "../../src/account/repo"
+import { AccountID, OrgID } from "../../src/account/schema"
+import { Database } from "../../src/storage/db"
+import { testEffect } from "../fixture/effect"
+
+const truncate = Layer.effectDiscard(
+ Effect.sync(() => {
+ const db = Database.Client()
+ db.run(/*sql*/ `DELETE FROM account_state`)
+ db.run(/*sql*/ `DELETE FROM account`)
+ }),
+)
+
+const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
+
+it.effect(
+ "list returns empty when no accounts exist",
+ Effect.gen(function* () {
+ const accounts = yield* AccountRepo.use((r) => r.list())
+ expect(accounts).toEqual([])
+ }),
+)
+
+it.effect(
+ "active returns none when no accounts exist",
+ Effect.gen(function* () {
+ const active = yield* AccountRepo.use((r) => r.active())
+ expect(Option.isNone(active)).toBe(true)
+ }),
+)
+
+it.effect(
+ "persistAccount inserts and getRow retrieves",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_123",
+ refreshToken: "rt_456",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.some(OrgID.make("org-1")),
+ }),
+ )
+
+ const row = yield* AccountRepo.use((r) => r.getRow(id))
+ expect(Option.isSome(row)).toBe(true)
+ const value = Option.getOrThrow(row)
+ expect(value.id).toBe("user-1")
+ expect(value.email).toBe("[email protected]")
+
+ const active = yield* AccountRepo.use((r) => r.active())
+ expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
+ }),
+)
+
+it.effect(
+ "persistAccount sets the active account and org",
+ Effect.gen(function* () {
+ const id1 = AccountID.make("user-1")
+ const id2 = AccountID.make("user-2")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: id1,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_1",
+ refreshToken: "rt_1",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.some(OrgID.make("org-1")),
+ }),
+ )
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: id2,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_2",
+ refreshToken: "rt_2",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.some(OrgID.make("org-2")),
+ }),
+ )
+
+ // Last persisted account is active with its org
+ const active = yield* AccountRepo.use((r) => r.active())
+ expect(Option.isSome(active)).toBe(true)
+ expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
+ expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
+ }),
+)
+
+it.effect(
+ "list returns all accounts",
+ Effect.gen(function* () {
+ const id1 = AccountID.make("user-1")
+ const id2 = AccountID.make("user-2")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: id1,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_1",
+ refreshToken: "rt_1",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: id2,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_2",
+ refreshToken: "rt_2",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.some(OrgID.make("org-1")),
+ }),
+ )
+
+ const accounts = yield* AccountRepo.use((r) => r.list())
+ expect(accounts.length).toBe(2)
+ expect(accounts.map((a) => a.email).sort()).toEqual(["[email protected]", "[email protected]"])
+ }),
+)
+
+it.effect(
+ "remove deletes an account",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_1",
+ refreshToken: "rt_1",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ yield* AccountRepo.use((r) => r.remove(id))
+
+ const row = yield* AccountRepo.use((r) => r.getRow(id))
+ expect(Option.isNone(row)).toBe(true)
+ }),
+)
+
+it.effect(
+ "use stores the selected org and marks the account active",
+ Effect.gen(function* () {
+ const id1 = AccountID.make("user-1")
+ const id2 = AccountID.make("user-2")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: id1,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_1",
+ refreshToken: "rt_1",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: id2,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_2",
+ refreshToken: "rt_2",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
+ const active1 = yield* AccountRepo.use((r) => r.active())
+ expect(Option.getOrThrow(active1).id).toBe(id1)
+ expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
+
+ yield* AccountRepo.use((r) => r.use(id1, Option.none()))
+ const active2 = yield* AccountRepo.use((r) => r.active())
+ expect(Option.getOrThrow(active2).active_org_id).toBeNull()
+ }),
+)
+
+it.effect(
+ "persistToken updates token fields",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "old_token",
+ refreshToken: "old_refresh",
+ expiry: 1000,
+ orgID: Option.none(),
+ }),
+ )
+
+ const expiry = Date.now() + 7200_000
+ yield* AccountRepo.use((r) =>
+ r.persistToken({
+ accountID: id,
+ accessToken: "new_token",
+ refreshToken: "new_refresh",
+ expiry: Option.some(expiry),
+ }),
+ )
+
+ const row = yield* AccountRepo.use((r) => r.getRow(id))
+ const value = Option.getOrThrow(row)
+ expect(value.access_token).toBe("new_token")
+ expect(value.refresh_token).toBe("new_refresh")
+ expect(value.token_expiry).toBe(expiry)
+ }),
+)
+
+it.effect(
+ "persistToken with no expiry sets token_expiry to null",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "old_token",
+ refreshToken: "old_refresh",
+ expiry: 1000,
+ orgID: Option.none(),
+ }),
+ )
+
+ yield* AccountRepo.use((r) =>
+ r.persistToken({
+ accountID: id,
+ accessToken: "new_token",
+ refreshToken: "new_refresh",
+ expiry: Option.none(),
+ }),
+ )
+
+ const row = yield* AccountRepo.use((r) => r.getRow(id))
+ expect(Option.getOrThrow(row).token_expiry).toBeNull()
+ }),
+)
+
+it.effect(
+ "persistAccount upserts on conflict",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_v1",
+ refreshToken: "rt_v1",
+ expiry: 1000,
+ orgID: Option.some(OrgID.make("org-1")),
+ }),
+ )
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_v2",
+ refreshToken: "rt_v2",
+ expiry: 2000,
+ orgID: Option.some(OrgID.make("org-2")),
+ }),
+ )
+
+ const accounts = yield* AccountRepo.use((r) => r.list())
+ expect(accounts.length).toBe(1)
+
+ const row = yield* AccountRepo.use((r) => r.getRow(id))
+ const value = Option.getOrThrow(row)
+ expect(value.access_token).toBe("at_v2")
+
+ const active = yield* AccountRepo.use((r) => r.active())
+ expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
+ }),
+)
+
+it.effect(
+ "remove clears active state when deleting the active account",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://control.example.com",
+ accessToken: "at_1",
+ refreshToken: "rt_1",
+ expiry: Date.now() + 3600_000,
+ orgID: Option.some(OrgID.make("org-1")),
+ }),
+ )
+
+ yield* AccountRepo.use((r) => r.remove(id))
+
+ const active = yield* AccountRepo.use((r) => r.active())
+ expect(Option.isNone(active)).toBe(true)
+ }),
+)
+
+it.effect(
+ "getRow returns none for nonexistent account",
+ Effect.gen(function* () {
+ const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
+ expect(Option.isNone(row)).toBe(true)
+ }),
+)
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
new file mode 100644
index 000000000..87f5b23f2
--- /dev/null
+++ b/packages/opencode/test/account/service.test.ts
@@ -0,0 +1,223 @@
+import { expect } from "bun:test"
+import { Effect, Layer, Option, Ref, Schema } from "effect"
+import { HttpClient, HttpClientResponse } from "effect/unstable/http"
+
+import { AccountRepo } from "../../src/account/repo"
+import { AccountService } from "../../src/account/service"
+import { AccountID, Login, Org, OrgID } from "../../src/account/schema"
+import { Database } from "../../src/storage/db"
+import { testEffect } from "../fixture/effect"
+
+const truncate = Layer.effectDiscard(
+ Effect.sync(() => {
+ const db = Database.Client()
+ db.run(/*sql*/ `DELETE FROM account_state`)
+ db.run(/*sql*/ `DELETE FROM account`)
+ }),
+)
+
+const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
+
+const live = (client: HttpClient.HttpClient) =>
+ AccountService.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client)))
+
+const json = (req: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
+ HttpClientResponse.fromWeb(
+ req,
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ }),
+ )
+
+const encodeOrg = Schema.encodeSync(Org)
+
+const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
+
+it.effect(
+ "orgsByAccount groups orgs per account",
+ Effect.gen(function* () {
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: AccountID.make("user-1"),
+ email: "[email protected]",
+ url: "https://one.example.com",
+ accessToken: "at_1",
+ refreshToken: "rt_1",
+ expiry: Date.now() + 60_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id: AccountID.make("user-2"),
+ email: "[email protected]",
+ url: "https://two.example.com",
+ accessToken: "at_2",
+ refreshToken: "rt_2",
+ expiry: Date.now() + 60_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ const seen = yield* Ref.make<string[]>([])
+ const client = HttpClient.make((req) =>
+ Effect.gen(function* () {
+ yield* Ref.update(seen, (xs) => [...xs, `${req.method} ${req.url}`])
+
+ if (req.url === "https://one.example.com/api/orgs") {
+ return json(req, [org("org-1", "One")])
+ }
+
+ if (req.url === "https://two.example.com/api/orgs") {
+ return json(req, [org("org-2", "Two A"), org("org-3", "Two B")])
+ }
+
+ return json(req, [], 404)
+ }),
+ )
+
+ const rows = yield* AccountService.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client)))
+
+ expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([
+ [AccountID.make("user-1"), [OrgID.make("org-1")]],
+ [AccountID.make("user-2"), [OrgID.make("org-2"), OrgID.make("org-3")]],
+ ])
+ expect(yield* Ref.get(seen)).toEqual([
+ "GET https://one.example.com/api/orgs",
+ "GET https://two.example.com/api/orgs",
+ ])
+ }),
+)
+
+it.effect(
+ "token refresh persists the new token",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://one.example.com",
+ accessToken: "at_old",
+ refreshToken: "rt_old",
+ expiry: Date.now() - 1_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ const client = HttpClient.make((req) =>
+ Effect.succeed(
+ req.url === "https://one.example.com/oauth/token"
+ ? json(req, {
+ access_token: "at_new",
+ refresh_token: "rt_new",
+ expires_in: 60,
+ })
+ : json(req, {}, 404),
+ ),
+ )
+
+ const token = yield* AccountService.use((s) => s.token(id)).pipe(Effect.provide(live(client)))
+
+ expect(Option.getOrThrow(token)).toBeDefined()
+ expect(String(Option.getOrThrow(token))).toBe("at_new")
+
+ const row = yield* AccountRepo.use((r) => r.getRow(id))
+ const value = Option.getOrThrow(row)
+ expect(value.access_token).toBe("at_new")
+ expect(value.refresh_token).toBe("rt_new")
+ expect(value.token_expiry).toBeGreaterThan(Date.now())
+ }),
+)
+
+it.effect(
+ "config sends the selected org header",
+ Effect.gen(function* () {
+ const id = AccountID.make("user-1")
+
+ yield* AccountRepo.use((r) =>
+ r.persistAccount({
+ id,
+ email: "[email protected]",
+ url: "https://one.example.com",
+ accessToken: "at_1",
+ refreshToken: "rt_1",
+ expiry: Date.now() + 60_000,
+ orgID: Option.none(),
+ }),
+ )
+
+ const seen = yield* Ref.make<{ auth?: string; org?: string }>({})
+ const client = HttpClient.make((req) =>
+ Effect.gen(function* () {
+ yield* Ref.set(seen, {
+ auth: req.headers.authorization,
+ org: req.headers["x-org-id"],
+ })
+
+ if (req.url === "https://one.example.com/api/config") {
+ return json(req, { config: { theme: "light", seats: 5 } })
+ }
+
+ return json(req, {}, 404)
+ }),
+ )
+
+ const cfg = yield* AccountService.use((s) => s.config(id, OrgID.make("org-9"))).pipe(Effect.provide(live(client)))
+
+ expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
+ expect(yield* Ref.get(seen)).toEqual({
+ auth: "Bearer at_1",
+ org: "org-9",
+ })
+ }),
+)
+
+it.effect(
+ "poll stores the account and first org on success",
+ Effect.gen(function* () {
+ const login = new Login({
+ code: "device-code",
+ user: "user-code",
+ url: "https://one.example.com/verify",
+ server: "https://one.example.com",
+ expiry: 600,
+ interval: 5,
+ })
+
+ const client = HttpClient.make((req) =>
+ Effect.succeed(
+ req.url === "https://one.example.com/auth/device/token"
+ ? json(req, {
+ access_token: "at_1",
+ refresh_token: "rt_1",
+ expires_in: 60,
+ })
+ : req.url === "https://one.example.com/api/user"
+ ? json(req, { id: "user-1", email: "[email protected]" })
+ : req.url === "https://one.example.com/api/orgs"
+ ? json(req, [org("org-1", "One")])
+ : json(req, {}, 404),
+ ),
+ )
+
+ const res = yield* AccountService.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
+
+ expect(res._tag).toBe("PollSuccess")
+ if (res._tag === "PollSuccess") {
+ expect(res.email).toBe("[email protected]")
+ }
+
+ const active = yield* AccountRepo.use((r) => r.active())
+ expect(Option.getOrThrow(active)).toEqual(
+ expect.objectContaining({
+ id: "user-1",
+ email: "[email protected]",
+ active_org_id: "org-1",
+ }),
+ )
+ }),
+)
diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts
index a1a69dc09..922c08114 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 3ce9094e9..5a1cf059d 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 96fac8cca..80394fbff 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 { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
@@ -242,6 +243,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_CONSOLE_TOKEN"]
+
+ Account.active = mock(() => ({
+ id: AccountID.make("account-1"),
+ email: "[email protected]",
+ url: "https://control.example.com",
+ active_org_id: OrgID.make("org-1"),
+ }))
+
+ Account.config = mock(async () => ({
+ provider: {
+ opencode: {
+ options: {
+ apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
+ },
+ },
+ },
+ }))
+
+ Account.token = mock(async () => AccessToken.make("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_CONSOLE_TOKEN"] = originalControlToken
+ } else {
+ delete process.env["OPENCODE_CONSOLE_TOKEN"]
+ }
+ }
+})
+
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/fixture/effect.ts b/packages/opencode/test/fixture/effect.ts
new file mode 100644
index 000000000..b75610139
--- /dev/null
+++ b/packages/opencode/test/fixture/effect.ts
@@ -0,0 +1,7 @@
+import { test } from "bun:test"
+import { Effect, Layer } from "effect"
+
+export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
+ effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
+ test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
+})
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 000000000..5be5d0245
--- /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 { AccessToken, Account, AccountID, OrgID } 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: AccountID.make("account-1"),
+ email: "[email protected]",
+ url: "https://control.example.com",
+ active_org_id: OrgID.make("org-1"),
+ }))
+ Account.token = mock(async () => AccessToken.make("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: AccountID.make("account-1"),
+ email: "[email protected]",
+ url: "https://control.example.com",
+ active_org_id: OrgID.make("org-1"),
+ }))
+ Account.token = mock(async () => undefined)
+
+ try {
+ await expect(ShareNext.request()).rejects.toThrow("No active account token available for sharing")
+ } finally {
+ Account.active = originalActive
+ Account.token = originalToken
+ }
+})
diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json
index 9067d84fd..44335bd80 100644
--- a/packages/opencode/tsconfig.json
+++ b/packages/opencode/tsconfig.json
@@ -11,6 +11,11 @@
"paths": {
"@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"]
- }
+ },
+ "plugins": [{
+ "name": "@effect/language-service",
+ "transform": "@effect/language-service/transform",
+ "namespaceImportPackages": ["effect", "@effect/*"]
+ }]
}
}