mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-07 23:23:57 +00:00
Compare commits
3 Commits
cli-auth-c
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0654f28c72 | ||
|
|
a32b76dee0 | ||
|
|
a52d640c8c |
@@ -122,7 +122,3 @@ const table = sqliteTable("session", {
|
||||
- Avoid mocks as much as possible
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||
|
||||
## Type Checking
|
||||
|
||||
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
||||
"effect": "4.0.0-beta.27",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -55,11 +55,6 @@ export function SessionSidePanel(props: {
|
||||
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
|
||||
return `${layout.fileTree.width()}px`
|
||||
})
|
||||
const reviewWidth = createMemo(() => {
|
||||
if (!reviewOpen()) return "0px"
|
||||
if (!fileOpen()) return "100%"
|
||||
return `calc(100% - ${layout.fileTree.width()}px)`
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
@@ -231,9 +226,8 @@ export function SessionSidePanel(props: {
|
||||
inert={!open()}
|
||||
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"opacity-100": open(),
|
||||
"opacity-0 pointer-events-none": !open(),
|
||||
"transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: panelWidth() }}
|
||||
@@ -242,14 +236,10 @@ export function SessionSidePanel(props: {
|
||||
<div
|
||||
aria-hidden={!reviewOpen()}
|
||||
inert={!reviewOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden bg-background-base"
|
||||
class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"opacity-100": reviewOpen(),
|
||||
"opacity-0 pointer-events-none": !reviewOpen(),
|
||||
"transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
"pointer-events-none": !reviewOpen(),
|
||||
}}
|
||||
style={{ width: reviewWidth() }}
|
||||
>
|
||||
<div class="size-full min-w-0 h-full bg-background-base">
|
||||
<DragDropProvider
|
||||
@@ -385,9 +375,8 @@ export function SessionSidePanel(props: {
|
||||
inert={!fileOpen()}
|
||||
class="relative min-w-0 h-full shrink-0 overflow-hidden"
|
||||
classList={{
|
||||
"opacity-100": fileOpen(),
|
||||
"opacity-0 pointer-events-none": !fileOpen(),
|
||||
"transition-[width,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
"pointer-events-none": !fileOpen(),
|
||||
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
}}
|
||||
style={{ width: treeWidth() }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -480,7 +480,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(محذوف)",
|
||||
"workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.",
|
||||
"workspace.cost.subscriptionShort": "اشتراك",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "مفاتيح API",
|
||||
"workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.",
|
||||
|
||||
@@ -488,7 +488,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(excluído)",
|
||||
"workspace.cost.empty": "Nenhum dado de uso disponível para o período selecionado.",
|
||||
"workspace.cost.subscriptionShort": "ass",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chaves de API",
|
||||
"workspace.keys.subtitle": "Gerencie suas chaves de API para acessar os serviços opencode.",
|
||||
|
||||
@@ -484,7 +484,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen brugsdata tilgængelige for den valgte periode.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøgler",
|
||||
"workspace.keys.subtitle": "Administrer dine API-nøgler for at få adgang til opencode-tjenester.",
|
||||
|
||||
@@ -487,7 +487,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(gelöscht)",
|
||||
"workspace.cost.empty": "Keine Nutzungsdaten für den gewählten Zeitraum verfügbar.",
|
||||
"workspace.cost.subscriptionShort": "Abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Verwalte deine API Keys für den Zugriff auf OpenCode-Dienste.",
|
||||
|
||||
@@ -480,7 +480,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(deleted)",
|
||||
"workspace.cost.empty": "No usage data available for the selected period.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "Manage your API keys for accessing opencode services.",
|
||||
|
||||
@@ -489,7 +489,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminado)",
|
||||
"workspace.cost.empty": "No hay datos de uso disponibles para el periodo seleccionado.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Claves API",
|
||||
"workspace.keys.subtitle": "Gestiona tus claves API para acceder a los servicios de opencode.",
|
||||
|
||||
@@ -490,7 +490,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(supprimé)",
|
||||
"workspace.cost.empty": "Aucune donnée d'utilisation disponible pour la période sélectionnée.",
|
||||
"workspace.cost.subscriptionShort": "abo",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Clés API",
|
||||
"workspace.keys.subtitle": "Gérez vos clés API pour accéder aux services OpenCode.",
|
||||
|
||||
@@ -487,7 +487,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(eliminato)",
|
||||
"workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Chiavi API",
|
||||
"workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.",
|
||||
|
||||
@@ -486,7 +486,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(削除済み)",
|
||||
"workspace.cost.empty": "選択した期間の使用状況データはありません。",
|
||||
"workspace.cost.subscriptionShort": "サブ",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "APIキー",
|
||||
"workspace.keys.subtitle": "OpenCodeサービスにアクセスするためのAPIキーを管理します。",
|
||||
|
||||
@@ -480,7 +480,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(삭제됨)",
|
||||
"workspace.cost.empty": "선택한 기간에 사용 데이터가 없습니다.",
|
||||
"workspace.cost.subscriptionShort": "구독",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 키",
|
||||
"workspace.keys.subtitle": "OpenCode 서비스 액세스를 위한 API 키를 관리하세요.",
|
||||
|
||||
@@ -485,7 +485,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(slettet)",
|
||||
"workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API-nøkler",
|
||||
"workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.",
|
||||
|
||||
@@ -486,7 +486,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(usunięte)",
|
||||
"workspace.cost.empty": "Brak danych o użyciu dla wybranego okresu.",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "Klucze API",
|
||||
"workspace.keys.subtitle": "Zarządzaj kluczami API do usług opencode.",
|
||||
|
||||
@@ -492,7 +492,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(удалено)",
|
||||
"workspace.cost.empty": "Нет данных об использовании за выбранный период.",
|
||||
"workspace.cost.subscriptionShort": "подписка",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Ключи",
|
||||
"workspace.keys.subtitle": "Управляйте вашими API ключами для доступа к сервисам opencode.",
|
||||
|
||||
@@ -483,7 +483,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(ลบแล้ว)",
|
||||
"workspace.cost.empty": "ไม่มีข้อมูลการใช้งานในช่วงเวลาที่เลือก",
|
||||
"workspace.cost.subscriptionShort": "sub",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Keys",
|
||||
"workspace.keys.subtitle": "จัดการ API keys ของคุณสำหรับการเข้าถึงบริการ OpenCode",
|
||||
|
||||
@@ -488,7 +488,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(silindi)",
|
||||
"workspace.cost.empty": "Seçilen döneme ait kullanım verisi yok.",
|
||||
"workspace.cost.subscriptionShort": "abonelik",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API Anahtarları",
|
||||
"workspace.keys.subtitle": "opencode hizmetlerine erişim için API anahtarlarınızı yönetin.",
|
||||
|
||||
@@ -463,7 +463,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(已删除)",
|
||||
"workspace.cost.empty": "所选期间无可用使用数据。",
|
||||
"workspace.cost.subscriptionShort": "订阅",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 密钥",
|
||||
"workspace.keys.subtitle": "管理访问 OpenCode 服务的 API 密钥。",
|
||||
|
||||
@@ -464,7 +464,6 @@ export const dict = {
|
||||
"workspace.cost.deletedSuffix": "(已刪除)",
|
||||
"workspace.cost.empty": "所選期間沒有可用的使用資料。",
|
||||
"workspace.cost.subscriptionShort": "訂",
|
||||
"workspace.cost.liteShort": "lite",
|
||||
|
||||
"workspace.keys.title": "API 金鑰",
|
||||
"workspace.keys.subtitle": "管理你的 API 金鑰以存取 OpenCode 服務。",
|
||||
|
||||
@@ -218,7 +218,7 @@ export function GraphSection() {
|
||||
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
|
||||
const colorBorder = styles.getPropertyValue("--color-border").trim()
|
||||
const subSuffix = ` (${i18n.t("workspace.cost.subscriptionShort")})`
|
||||
const liteSuffix = ` (${i18n.t("workspace.cost.liteShort")})`
|
||||
const liteSuffix = " (go)"
|
||||
|
||||
const dailyDataRegular = new Map<string, Map<string, number>>()
|
||||
const dailyDataSub = new Map<string, Map<string, number>>()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.20"
|
||||
version = "1.2.21"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.20/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.21/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -27,7 +27,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@effect/language-service": "0.78.0",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -108,7 +107,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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 toAccountRepoError = (operation: string, message: string, cause?: unknown) =>
|
||||
new AccountRepoError({ operation, message, cause })
|
||||
|
||||
const db = <A>(run: (db: DbClient) => A) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(run),
|
||||
catch: (cause) => toAccountRepoError("db", "Database operation failed", cause),
|
||||
})
|
||||
|
||||
const fromRow = (row: AccountRow) => decodeAccount(row)
|
||||
|
||||
const current = (db: DbClient) => {
|
||||
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, 1)).get()
|
||||
if (!state?.active_account_id) return
|
||||
return db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
|
||||
}
|
||||
|
||||
const setActive = (db: DbClient, accountID: AccountID) =>
|
||||
db
|
||||
.insert(AccountStateTable)
|
||||
.values({ id: 1, active_account_id: accountID })
|
||||
.onConflictDoUpdate({
|
||||
target: AccountStateTable.id,
|
||||
set: { active_account_id: accountID },
|
||||
})
|
||||
.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(fromRow(row)) : Option.none()))),
|
||||
),
|
||||
|
||||
list: Effect.fn("AccountRepo.list")(() => db((db) => db.select().from(AccountTable).all().map(fromRow))),
|
||||
|
||||
remove: Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
||||
db((db) =>
|
||||
Database.transaction((tx) => {
|
||||
tx.update(AccountStateTable)
|
||||
.set({ active_account_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) =>
|
||||
Database.transaction((tx) => {
|
||||
tx.update(AccountTable)
|
||||
.set({ selected_org_id: Option.getOrNull(orgID) })
|
||||
.where(eq(AccountTable.id, accountID))
|
||||
.run()
|
||||
setActive(tx, accountID)
|
||||
}),
|
||||
).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 Effect.try({
|
||||
try: () =>
|
||||
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,
|
||||
selected_org_id: orgID,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
selected_org_id: orgID,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
setActive(tx, input.id)
|
||||
}),
|
||||
catch: (cause) => toAccountRepoError("db", "Database operation failed", cause),
|
||||
}).pipe(Effect.asVoid)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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,
|
||||
selected_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", {
|
||||
operation: Schema.String,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
|
||||
operation: Schema.String,
|
||||
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>
|
||||
@@ -1,386 +0,0 @@
|
||||
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,
|
||||
AccountRepoError,
|
||||
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 serverDefault = "https://web-14275-d60e67f5-pyqs0590.onporter.run"
|
||||
const clientId = "opencode-cli"
|
||||
|
||||
const toAccountServiceError = (operation: string, message: string, cause?: unknown) =>
|
||||
new AccountServiceError({ operation, 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(operation, message, 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: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
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* (url?: string) {
|
||||
const server = url ?? serverDefault
|
||||
|
||||
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("login", `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 ?? ""
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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
|
||||
|
||||
if (email) {
|
||||
const accounts = yield* service.list()
|
||||
const match = accounts.find((a) => a.email === email)
|
||||
if (!match) return yield* println("Account not found: " + email)
|
||||
yield* service.remove(match.id)
|
||||
yield* println("Logged out from " + email)
|
||||
return
|
||||
}
|
||||
|
||||
const active = yield* service.active()
|
||||
if (Option.isNone(active)) return yield* println("Not logged in")
|
||||
yield* service.remove(active.value.id)
|
||||
yield* println("Logged out from " + active.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.selected_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.selected_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: "log in to an opencode account",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "server URL",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(loginEffect(args.url))
|
||||
},
|
||||
})
|
||||
|
||||
export const LogoutCommand = cmd({
|
||||
command: "logout [email]",
|
||||
describe: "log out from an account",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("email", {
|
||||
describe: "account email to log out from",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
UI.empty()
|
||||
await runtime.runPromise(logoutEffect(args.email))
|
||||
},
|
||||
})
|
||||
|
||||
export const SwitchCommand = cmd({
|
||||
command: "switch",
|
||||
describe: "switch active org",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(switchEffect())
|
||||
},
|
||||
})
|
||||
|
||||
export const OrgsCommand = cmd({
|
||||
command: "orgs",
|
||||
describe: "list all orgs",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
await runtime.runPromise(orgsEffect())
|
||||
},
|
||||
})
|
||||
@@ -13,13 +13,27 @@ 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"]>
|
||||
|
||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise<boolean> {
|
||||
/**
|
||||
* 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 (plugin.auth.methods.length > 1) {
|
||||
const method = await prompts.select({
|
||||
if (methodName) {
|
||||
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
|
||||
if (match === -1) {
|
||||
prompts.log.error(
|
||||
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
index = match
|
||||
} else if (plugin.auth.methods.length > 1) {
|
||||
const selected = await prompts.select({
|
||||
message: "Login method",
|
||||
options: [
|
||||
...plugin.auth.methods.map((x, index) => ({
|
||||
@@ -28,12 +42,13 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
|
||||
})),
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(method)) throw new UI.CancelledError()
|
||||
index = parseInt(method)
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
index = parseInt(selected)
|
||||
}
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
await Bun.sleep(10)
|
||||
// Handle prompts for all auth types
|
||||
await sleep(10)
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
@@ -156,6 +171,11 @@ 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>
|
||||
@@ -183,20 +203,19 @@ export function resolvePluginProviders(input: {
|
||||
return result
|
||||
}
|
||||
|
||||
export const ProvidersCommand = cmd({
|
||||
command: "providers",
|
||||
aliases: ["auth"],
|
||||
describe: "manage AI providers and credentials",
|
||||
export const AuthCommand = cmd({
|
||||
command: "auth",
|
||||
describe: "manage credentials",
|
||||
builder: (yargs) =>
|
||||
yargs.command(ProvidersListCommand).command(ProvidersLoginCommand).command(ProvidersLogoutCommand).demandCommand(),
|
||||
yargs.command(AuthLoginCommand).command(AuthLogoutCommand).command(AuthListCommand).demandCommand(),
|
||||
async handler() {},
|
||||
})
|
||||
|
||||
export const ProvidersListCommand = cmd({
|
||||
export const AuthListCommand = cmd({
|
||||
command: "list",
|
||||
aliases: ["ls"],
|
||||
describe: "list providers and credentials",
|
||||
async handler(_args) {
|
||||
describe: "list providers",
|
||||
async handler() {
|
||||
UI.empty()
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
const homedir = os.homedir()
|
||||
@@ -212,6 +231,7 @@ export const ProvidersListCommand = cmd({
|
||||
|
||||
prompts.outro(`${results.length} credentials`)
|
||||
|
||||
// Environment variables section
|
||||
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
|
||||
|
||||
for (const [providerID, provider] of Object.entries(database)) {
|
||||
@@ -238,14 +258,25 @@ export const ProvidersListCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
export const ProvidersLoginCommand = cmd({
|
||||
export const AuthLoginCommand = cmd({
|
||||
command: "login [url]",
|
||||
describe: "log in to a provider",
|
||||
builder: (yargs) =>
|
||||
yargs.positional("url", {
|
||||
describe: "opencode auth provider",
|
||||
type: "string",
|
||||
}),
|
||||
yargs
|
||||
.positional("url", {
|
||||
describe: "opencode auth provider",
|
||||
type: "string",
|
||||
})
|
||||
.option("provider", {
|
||||
alias: ["p"],
|
||||
describe: "provider id or name to log in to (skips provider selection)",
|
||||
type: "string",
|
||||
})
|
||||
.option("method", {
|
||||
alias: ["m"],
|
||||
describe: "login method label (skips method selection)",
|
||||
type: "string",
|
||||
}),
|
||||
async handler(args) {
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
@@ -253,7 +284,8 @@ export const ProvidersLoginCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
const url = args.url.replace(/\/+$/, "")
|
||||
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Process.spawn(wellknown.auth.command, {
|
||||
stdout: "pipe",
|
||||
@@ -269,12 +301,12 @@ export const ProvidersLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await Auth.set(args.url, {
|
||||
await Auth.set(url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
})
|
||||
prompts.log.success("Logged into " + args.url)
|
||||
prompts.log.success("Logged into " + url)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
@@ -311,59 +343,76 @@ export const ProvidersLoginCommand = cmd({
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
})
|
||||
let provider = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
anthropic: "Claude Max or API key",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
const options = [
|
||||
...pipe(
|
||||
providers,
|
||||
values(),
|
||||
sortBy(
|
||||
(x) => priority[x.id] ?? 99,
|
||||
(x) => x.name ?? x.id,
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
hint: {
|
||||
opencode: "recommended",
|
||||
anthropic: "Claude Max or API key",
|
||||
openai: "ChatGPT Plus/Pro or API key",
|
||||
}[x.id],
|
||||
})),
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
...pluginProviders.map((x) => ({
|
||||
label: x.name,
|
||||
value: x.id,
|
||||
hint: "plugin",
|
||||
})),
|
||||
]
|
||||
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
let provider: string
|
||||
if (args.provider) {
|
||||
const input = args.provider
|
||||
const byID = options.find((x) => x.value === input)
|
||||
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
|
||||
const match = byID ?? byName
|
||||
if (!match) {
|
||||
prompts.log.error(`Unknown provider "${input}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
provider = match.value
|
||||
} else {
|
||||
const selected = await prompts.autocomplete({
|
||||
message: "Select provider",
|
||||
maxItems: 8,
|
||||
options: [
|
||||
...options,
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
provider = selected as string
|
||||
}
|
||||
|
||||
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
if (provider === "other") {
|
||||
provider = await prompts.text({
|
||||
const custom = await prompts.text({
|
||||
message: "Enter provider id",
|
||||
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
|
||||
})
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
provider = provider.replace(/^@ai-sdk\//, "")
|
||||
if (prompts.isCancel(provider)) throw new UI.CancelledError()
|
||||
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)
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
}
|
||||
|
||||
@@ -412,10 +461,10 @@ export const ProvidersLoginCommand = cmd({
|
||||
},
|
||||
})
|
||||
|
||||
export const ProvidersLogoutCommand = cmd({
|
||||
export const AuthLogoutCommand = cmd({
|
||||
command: "logout",
|
||||
describe: "log out from a configured provider",
|
||||
async handler(_args) {
|
||||
async handler() {
|
||||
UI.empty()
|
||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||
prompts.intro("Remove credential")
|
||||
@@ -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/shares/:id/data) */
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/share/:id/data) */
|
||||
export type ShareData =
|
||||
| { type: "session"; data: SDKSession }
|
||||
| { type: "message"; data: Message }
|
||||
@@ -24,14 +24,6 @@ export function parseShareUrl(url: string): string | null {
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export function shouldAttachShareAuthHeaders(shareUrl: string, controlBaseUrl: string): boolean {
|
||||
try {
|
||||
return new URL(shareUrl).origin === new URL(controlBaseUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform ShareNext API response (flat array) into the nested structure for local file storage.
|
||||
*
|
||||
@@ -105,21 +97,8 @@ export const ImportCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
const baseUrl = await ShareNext.url()
|
||||
const response = await fetch(`${baseUrl}/api/share/${slug}/data`)
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
|
||||
@@ -377,12 +377,7 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then((res) => copy(res.data!.share!.url))
|
||||
.catch((error) => {
|
||||
toast.show({
|
||||
message: error instanceof Error ? error.message : "Failed to share session",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
.catch(() => toast.show({ message: "Failed to share session", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -485,12 +480,7 @@ export function Session() {
|
||||
sessionID: route.sessionID,
|
||||
})
|
||||
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
|
||||
.catch((error) => {
|
||||
toast.show({
|
||||
message: error instanceof Error ? error.message : "Failed to unshare session",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
.catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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,
|
||||
@@ -33,7 +32,7 @@ import { Glob } from "../util/glob"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Account } from "@/account"
|
||||
import { Control } from "@/control"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
@@ -109,6 +108,10 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
const token = await Control.token()
|
||||
if (token) {
|
||||
}
|
||||
|
||||
// Global user config overrides remote config.
|
||||
result = mergeConfigConcatArrays(result, await global())
|
||||
|
||||
@@ -175,26 +178,6 @@ export namespace Config {
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = Account.active()
|
||||
if (active?.selected_org_id) {
|
||||
const config = await Account.config(active.id, active.selected_org_id)
|
||||
const token = await Account.token(active.id)
|
||||
if (token) {
|
||||
process.env["OPENCODE_CONTROL_TOKEN"] = token
|
||||
Env.set("OPENCODE_CONTROL_TOKEN", token)
|
||||
}
|
||||
|
||||
if (config) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
await load(JSON.stringify(config), {
|
||||
dir: path.dirname(`${active.url}/api/config`),
|
||||
source: `${active.url}/api/config`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Load managed config files last (highest priority) - enterprise admin-controlled
|
||||
// Kept separate from directories array to avoid write operations when installing plugins
|
||||
// which would fail on system directories requiring elevated permissions
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import { eq } from "drizzle-orm"
|
||||
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(),
|
||||
selected_org_id: text(),
|
||||
...Timestamps,
|
||||
})
|
||||
|
||||
export const AccountStateTable = sqliteTable("account_state", {
|
||||
id: integer().primaryKey(),
|
||||
active_account_id: text().references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
export const ControlAccountTable = sqliteTable(
|
||||
"control_account",
|
||||
{
|
||||
67
packages/opencode/src/control/index.ts
Normal file
67
packages/opencode/src/control/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { ManagedRuntime } from "effect"
|
||||
import { AccountService } from "@/account/service"
|
||||
|
||||
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)
|
||||
@@ -3,8 +3,7 @@ import { hideBin } from "yargs/helpers"
|
||||
import { RunCommand } from "./cli/cmd/run"
|
||||
import { GenerateCommand } from "./cli/cmd/generate"
|
||||
import { Log } from "./util/log"
|
||||
import { LoginCommand, LogoutCommand, SwitchCommand, OrgsCommand } from "./cli/cmd/account"
|
||||
import { ProvidersCommand } from "./cli/cmd/providers"
|
||||
import { AuthCommand } from "./cli/cmd/auth"
|
||||
import { AgentCommand } from "./cli/cmd/agent"
|
||||
import { UpgradeCommand } from "./cli/cmd/upgrade"
|
||||
import { UninstallCommand } from "./cli/cmd/uninstall"
|
||||
@@ -135,11 +134,7 @@ let cli = yargs(hideBin(process.argv))
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
.command(DebugCommand)
|
||||
.command(LoginCommand)
|
||||
.command(LogoutCommand)
|
||||
.command(SwitchCommand)
|
||||
.command(OrgsCommand)
|
||||
.command(ProvidersCommand)
|
||||
.command(AuthCommand)
|
||||
.command(AgentCommand)
|
||||
.command(UpgradeCommand)
|
||||
.command(UninstallCommand)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Account } from "@/account"
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session"
|
||||
@@ -12,51 +11,8 @@ import type * as SDK from "@opencode-ai/sdk/v2"
|
||||
export namespace ShareNext {
|
||||
const log = Log.create({ service: "share-next" })
|
||||
|
||||
type ApiEndpoints = {
|
||||
create: string
|
||||
sync: (shareId: string) => string
|
||||
remove: (shareId: string) => string
|
||||
data: (shareId: string) => string
|
||||
}
|
||||
|
||||
function apiEndpoints(resource: string): ApiEndpoints {
|
||||
return {
|
||||
create: `/api/${resource}`,
|
||||
sync: (shareId) => `/api/${resource}/${shareId}/sync`,
|
||||
remove: (shareId) => `/api/${resource}/${shareId}`,
|
||||
data: (shareId) => `/api/${resource}/${shareId}/data`,
|
||||
}
|
||||
}
|
||||
|
||||
const legacyApi = apiEndpoints("share")
|
||||
const controlApi = apiEndpoints("shares")
|
||||
|
||||
export async function url() {
|
||||
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?.selected_org_id) {
|
||||
const baseUrl = await Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
return { headers, api: legacyApi, baseUrl }
|
||||
}
|
||||
|
||||
const token = await Account.token(active.id)
|
||||
if (!token) {
|
||||
throw new Error("No active OpenControl token available for sharing")
|
||||
}
|
||||
|
||||
headers["authorization"] = `Bearer ${token}`
|
||||
headers["x-org-id"] = active.selected_org_id
|
||||
return { headers, api: controlApi, baseUrl: active.url }
|
||||
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
|
||||
}
|
||||
|
||||
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
|
||||
@@ -112,20 +68,15 @@ export namespace ShareNext {
|
||||
export async function create(sessionID: string) {
|
||||
if (disabled) return { id: "", url: "", secret: "" }
|
||||
log.info("creating share", { sessionID })
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.create}`, {
|
||||
const result = await fetch(`${await url()}/api/share`, {
|
||||
method: "POST",
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ sessionID: sessionID }),
|
||||
})
|
||||
|
||||
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 }
|
||||
|
||||
.then((x) => x.json())
|
||||
.then((x) => x as { id: string; url: string; secret: string })
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionShareTable)
|
||||
@@ -208,19 +159,16 @@ export namespace ShareNext {
|
||||
const share = get(sessionID)
|
||||
if (!share) return
|
||||
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.sync(share.id)}`, {
|
||||
await fetch(`${await url()}/api/share/${share.id}/sync`, {
|
||||
method: "POST",
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
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 })
|
||||
}
|
||||
@@ -230,21 +178,15 @@ export namespace ShareNext {
|
||||
log.info("removing share", { sessionID })
|
||||
const share = get(sessionID)
|
||||
if (!share) return
|
||||
|
||||
const req = await request()
|
||||
const response = await fetch(`${req.baseUrl}${req.api.remove(share.id)}`, {
|
||||
await fetch(`${await url()}/api/share/${share.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { ...req.headers, "Content-Type": "application/json" },
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export namespace Database {
|
||||
type Schema = typeof schema
|
||||
export type Transaction = SQLiteTransaction<"sync", void, Schema>
|
||||
|
||||
type Client = SQLiteBunDatabase
|
||||
type Client = SQLiteBunDatabase<Schema>
|
||||
|
||||
type Journal = { sql: string; timestamp: number; name: string }[]
|
||||
|
||||
@@ -94,7 +94,7 @@ export namespace Database {
|
||||
sqlite.run("PRAGMA foreign_keys = ON")
|
||||
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const db = drizzle({ client: sqlite, schema })
|
||||
|
||||
// Apply schema migrations
|
||||
const entries =
|
||||
@@ -120,7 +120,7 @@ export namespace Database {
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
|
||||
export type TxOrDb = Transaction | Client
|
||||
|
||||
const ctx = Context.create<{
|
||||
tx: TxOrDb
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { ControlAccountTable } from "../control/control.sql"
|
||||
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
|
||||
export { SessionShareTable } from "../share/share.sql"
|
||||
export { ProjectTable } from "../project/project.sql"
|
||||
export { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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),
|
||||
}),
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
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))
|
||||
@@ -1,337 +0,0 @@
|
||||
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 { resetDatabase } from "../fixture/db"
|
||||
import { testEffect } from "../fixture/effect"
|
||||
|
||||
const reset = Layer.effectDiscard(Effect.promise(() => resetDatabase()))
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, reset))
|
||||
|
||||
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: "test@example.com",
|
||||
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("test@example.com")
|
||||
expect(value.selected_org_id).toBe("org-1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"persistAccount sets the active account without clearing prior selections",
|
||||
Effect.gen(function* () {
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "first@example.com",
|
||||
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: "second@example.com",
|
||||
url: "https://control.example.com",
|
||||
accessToken: "at_2",
|
||||
refreshToken: "rt_2",
|
||||
expiry: Date.now() + 3600_000,
|
||||
orgID: Option.some(OrgID.make("org-2")),
|
||||
}),
|
||||
)
|
||||
|
||||
const row1 = yield* AccountRepo.use((r) => r.getRow(id1))
|
||||
expect(Option.getOrThrow(row1).selected_org_id).toBe("org-1")
|
||||
|
||||
const row2 = yield* AccountRepo.use((r) => r.getRow(id2))
|
||||
expect(Option.getOrThrow(row2).selected_org_id).toBe("org-2")
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.isSome(active)).toBe(true)
|
||||
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-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: "a@example.com",
|
||||
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: "b@example.com",
|
||||
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(["a@example.com", "b@example.com"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"remove deletes an account",
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
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: "first@example.com",
|
||||
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: "second@example.com",
|
||||
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 row = yield* AccountRepo.use((r) => r.getRow(id1))
|
||||
expect(Option.getOrThrow(row).selected_org_id).toBe("org-99")
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active).id).toBe(id1)
|
||||
|
||||
yield* AccountRepo.use((r) => r.use(id1, Option.none()))
|
||||
const row2 = yield* AccountRepo.use((r) => r.getRow(id1))
|
||||
expect(Option.getOrThrow(row2).selected_org_id).toBeNull()
|
||||
|
||||
const other = yield* AccountRepo.use((r) => r.getRow(id2))
|
||||
expect(Option.getOrThrow(other).selected_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: "test@example.com",
|
||||
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: "test@example.com",
|
||||
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: "test@example.com",
|
||||
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: "test@example.com",
|
||||
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")
|
||||
expect(value.selected_org_id).toBe("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: "test@example.com",
|
||||
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)
|
||||
}),
|
||||
)
|
||||
@@ -1,217 +0,0 @@
|
||||
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 { resetDatabase } from "../fixture/db"
|
||||
import { testEffect } from "../fixture/effect"
|
||||
|
||||
const reset = Layer.effectDiscard(Effect.promise(() => resetDatabase()))
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, reset))
|
||||
|
||||
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: "one@example.com",
|
||||
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: "two@example.com",
|
||||
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: "user@example.com",
|
||||
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: "user@example.com",
|
||||
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: "user@example.com" })
|
||||
: 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("user@example.com")
|
||||
}
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
selected_org_id: "org-1",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -1,10 +1,5 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import {
|
||||
parseShareUrl,
|
||||
shouldAttachShareAuthHeaders,
|
||||
transformShareData,
|
||||
type ShareData,
|
||||
} from "../../src/cli/cmd/import"
|
||||
import { parseShareUrl, transformShareData, type ShareData } from "../../src/cli/cmd/import"
|
||||
|
||||
// parseShareUrl tests
|
||||
test("parses valid share URLs", () => {
|
||||
@@ -20,19 +15,6 @@ 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[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { resolvePluginProviders } from "../../src/cli/cmd/providers"
|
||||
import { resolvePluginProviders } from "../../src/cli/cmd/auth"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
|
||||
function hookWithAuth(provider: string): Hooks {
|
||||
|
||||
@@ -2,7 +2,6 @@ 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"
|
||||
@@ -243,52 +242,6 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("resolves env templates in account config with account token", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalConfig = Account.config
|
||||
const originalToken = Account.token
|
||||
const originalControlToken = process.env["OPENCODE_CONTROL_TOKEN"]
|
||||
|
||||
Account.active = mock(() => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
selected_org_id: OrgID.make("org-1"),
|
||||
}))
|
||||
|
||||
Account.config = mock(async () => ({
|
||||
provider: {
|
||||
opencode: {
|
||||
options: {
|
||||
apiKey: "{env:OPENCODE_CONTROL_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_CONTROL_TOKEN"] = originalControlToken
|
||||
} else {
|
||||
delete process.env["OPENCODE_CONTROL_TOKEN"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file inclusion substitution", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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)))),
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
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: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
selected_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: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
selected_org_id: OrgID.make("org-1"),
|
||||
}))
|
||||
Account.token = mock(async () => undefined)
|
||||
|
||||
try {
|
||||
await expect(ShareNext.request()).rejects.toThrow("No active OpenControl token available for sharing")
|
||||
} finally {
|
||||
Account.active = originalActive
|
||||
Account.token = originalToken
|
||||
}
|
||||
})
|
||||
@@ -11,11 +11,6 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@tui/*": ["./src/cli/cmd/tui/*"]
|
||||
},
|
||||
"plugins": [{
|
||||
"name": "@effect/language-service",
|
||||
"transform": "@effect/language-service/transform",
|
||||
"namespaceImportPackages": ["effect", "@effect/*"]
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.2.20",
|
||||
"version": "1.2.21",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user