core: enable workspace-aware configuration and account management commands

Switch from boolean active flag to workspace_id tracking so users can select which workspace context to operate in. Login now automatically selects the first available workspace and stores it on the account record.

Logout command now actually removes account records and supports targeting specific accounts by email. Switch command provides an interactive picker to change active workspace. Workspaces command lists all available workspaces across accounts.

Configuration now loads workspace-specific settings from the server when an active workspace is selected, enabling per-workspace customization of opencode behavior.
This commit is contained in:
Dax Raad
2026-02-28 15:30:42 -05:00
parent 7b5b665b4a
commit a5d727e7f9
6 changed files with 1147 additions and 18 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE `account` ADD `workspace_id` text;--> statement-breakpoint
ALTER TABLE `account` DROP COLUMN `active`;

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,6 @@ export const AccountTable = sqliteTable("account", {
access_token: text().notNull(),
refresh_token: text().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
workspace_id: text(),
...Timestamps,
})

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"
import { eq, sql, isNotNull } from "drizzle-orm"
import { Database } from "@/storage/db"
import { AccountTable } from "./account.sql"
import z from "zod"
@@ -8,6 +8,7 @@ export namespace Account {
id: z.string(),
email: z.string(),
url: z.string(),
workspace_id: z.string().nullable(),
})
export type Account = z.infer<typeof Account>
@@ -16,11 +17,12 @@ export namespace Account {
id: row.id,
email: row.email,
url: row.url,
workspace_id: row.workspace_id,
}
}
export function active(): Account | undefined {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.active, true)).get())
const row = Database.use((db) => db.select().from(AccountTable).where(isNotNull(AccountTable.workspace_id)).get())
return row ? fromRow(row) : undefined
}
@@ -28,6 +30,16 @@ export namespace Account {
return Database.use((db) => db.select().from(AccountTable).all().map(fromRow))
}
export function remove(accountID: string) {
Database.use((db) => db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run())
}
export function use(accountID: string, workspaceID: string | null) {
Database.use((db) =>
db.update(AccountTable).set({ workspace_id: workspaceID }).where(eq(AccountTable.id, accountID)).run(),
)
}
export async function workspaces(accountID: string): Promise<{ id: string; name: string }[]> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return []
@@ -45,6 +57,22 @@ export namespace Account {
return json.map((x) => ({ id: x.id ?? "", name: x.name ?? "" }))
}
export async function config(accountID: string, workspaceID: string): Promise<Record<string, unknown> | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return undefined
const access = await token(accountID)
if (!access) return undefined
const res = await fetch(`${row.url}/api/config`, {
headers: { authorization: `Bearer ${access}`, "x-org-id": workspaceID },
})
if (!res.ok) return undefined
const result = (await res.json()) as Record<string, any>
return result.config
}
export async function token(accountID: string): Promise<string | undefined> {
const row = Database.use((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get())
if (!row) return undefined
@@ -164,17 +192,24 @@ export namespace Account {
const expiry = Date.now() + json.expires_in! * 1000
const refresh = json.refresh_token ?? ""
// Fetch workspaces and get first one
const orgsRes = await fetch(`${input.server}/api/orgs`, {
headers: { authorization: `Bearer ${access}` },
})
const orgs = (await orgsRes.json()) as Array<{ id?: string; name?: string }>
const firstWorkspaceId = orgs.length > 0 ? orgs[0].id : null
Database.use((db) => {
db.update(AccountTable).set({ active: false }).run()
db.update(AccountTable).set({ workspace_id: null }).run()
db.insert(AccountTable)
.values({
id,
email,
url: input.url,
url: input.server,
access_token: access,
refresh_token: refresh,
token_expiry: expiry,
active: true,
workspace_id: firstWorkspaceId,
})
.onConflictDoUpdate({
target: AccountTable.id,
@@ -182,7 +217,7 @@ export namespace Account {
access_token: access,
refresh_token: refresh,
token_expiry: expiry,
active: true,
workspace_id: firstWorkspaceId,
},
})
.run()

View File

@@ -70,20 +70,92 @@ export const LoginCommand = cmd({
})
export const LogoutCommand = cmd({
command: "logout",
command: "logout [email]",
describe: "log out from an account",
async handler() {},
builder: (yargs) =>
yargs.positional("email", {
describe: "account email to log out from",
type: "string",
}),
async handler(args) {
const email = args.email as string | undefined
if (email) {
const accounts = Account.list()
const match = accounts.find((a) => a.email === email)
if (!match) {
UI.println("Account not found: " + email)
return
}
Account.remove(match.id)
UI.println("Logged out from " + email)
return
}
const active = Account.active()
if (!active) {
UI.println("Not logged in")
return
}
Account.remove(active.id)
UI.println("Logged out from " + active.email)
},
})
export const SwitchCommand = cmd({
command: "switch",
describe: "switch active workspace",
async handler() {},
async handler() {
UI.empty()
const active = Account.active()
if (!active) {
UI.println("Not logged in")
return
}
const workspaces = await Account.workspaces(active.id)
if (workspaces.length === 0) {
UI.println("No workspaces found")
return
}
prompts.intro("Switch workspace")
const opts = workspaces.map((w) => ({
value: w.id,
label: w.id === active.workspace_id ? w.name + UI.Style.TEXT_DIM + " (active)" : w.name,
}))
const selected = await prompts.select({
message: "Select workspace",
options: opts,
})
if (prompts.isCancel(selected)) return
Account.use(active.id, selected as string)
prompts.outro("Switched to " + workspaces.find((w) => w.id === selected)?.name)
},
})
export const WorkspacesCommand = cmd({
command: "workspaces",
aliases: ["workspace"],
describe: "list all workspaces",
async handler() {},
async handler() {
const accounts = Account.list()
if (accounts.length === 0) {
UI.println("No accounts found")
return
}
for (const account of accounts) {
const workspaces = await Account.workspaces(account.id)
for (const space of workspaces) {
UI.println([space.name, account.email, space.id].join("\t"))
}
}
},
})

View File

@@ -107,11 +107,6 @@ export namespace Config {
}
}
const active = Account.active()
const token = active ? await Account.token(active.id) : undefined
if (token) {
}
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
@@ -178,6 +173,15 @@ export namespace Config {
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const active = Account.active()
if (active?.workspace_id) {
const config = await Account.config(active.id, active.workspace_id)
result = mergeConfigConcatArrays(result, config ?? {})
const token = await Account.token(active.id)
// TODO: this is bad
process.env["OPENCODE_CONTROL_TOKEN"] = token
}
// Load managed config files last (highest priority) - enterprise admin-controlled
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions