diff options
| author | Dax <[email protected]> | 2026-03-10 12:53:47 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-10 12:53:47 -0400 |
| commit | 613562f5047aa0bad934da401146a04ed8adce84 (patch) | |
| tree | 5f45b1f33ab6231c2eb9921c384a42de09e10077 /packages | |
| parent | 9c4325bcf8070d84a6911ae78b898c116ebad2ac (diff) | |
| download | opencode-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')
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/*"] + }] } } |
