From 1024884dfd415968aa85ae1d8afb2773cd3f81cc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 5 Aug 2026 16:13:53 -0400 Subject: [PATCH] feat(core): import legacy credentials --- packages/core/src/database/migration.gen.ts | 1 + ...0260805200742_import_legacy_credentials.ts | 102 ++++++++++++++++++ packages/core/test/database-migration.test.ts | 59 ++++++++++ 3 files changed, 162 insertions(+) create mode 100644 packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 2bdaf4453b7..a247e95b29a 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -41,5 +41,6 @@ export const migrations = ( import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), import("./migration/20260804233008_loose_psylocke"), + import("./migration/20260805200742_import_legacy_credentials"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts b/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts new file mode 100644 index 00000000000..38d95517e68 --- /dev/null +++ b/packages/core/src/database/migration/20260805200742_import_legacy_credentials.ts @@ -0,0 +1,102 @@ +import path from "node:path" +import { sql } from "drizzle-orm" +import { Effect, Option, Schema } from "effect" +import { Credential } from "@opencode-ai/schema/credential" +import { Integration } from "@opencode-ai/schema/integration" +import { NonNegativeInt } from "@opencode-ai/schema/schema" +import { Global } from "@opencode-ai/util/global" +import type { DatabaseMigration } from "../migration" + +const LegacyOAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) +const LegacyKey = Schema.Struct({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) +const LegacyWellKnown = Schema.Struct({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) +const LegacyValue = Schema.Union([LegacyOAuth, LegacyKey, LegacyWellKnown]) +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const decodeValue = Schema.decodeUnknownOption(LegacyValue) +const wellKnownSourcesKey = "wellknown:sources" + +export default { + id: "20260805200742_import_legacy_credentials", + up(tx) { + return importLegacyCredentials(tx, path.join(Global.Path.data, "auth.json")) + }, +} satisfies DatabaseMigration.Migration + +export function importLegacyCredentials(tx: Parameters[0], filepath: string) { + return Effect.gen(function* () { + const file = Bun.file(filepath) + if (!(yield* Effect.promise(() => file.exists()))) return + const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text()))) + if (typeof input !== "object" || input === null || Array.isArray(input)) { + return yield* Effect.fail(new Error("Legacy credential file must contain an object")) + } + + const origins: string[] = [] + for (const [id, raw] of Object.entries(input)) { + const value = Option.getOrUndefined(decodeValue(raw)) + if (!value) continue + const integrationID = id.replace(/\/+$/, "") + if (!integrationID) continue + if (value.type === "wellknown") origins.push(integrationID) + if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue + + const credential = + value.type === "api" + ? Credential.Key.make({ type: "key", key: value.key, metadata: value.metadata }) + : value.type === "wellknown" + ? Credential.Key.make({ type: "key", key: value.token }) + : Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make(methodID(integrationID)), + refresh: value.refresh, + access: value.access, + expires: value.expires, + metadata: + value.accountId || value.enterpriseUrl + ? { + ...(value.accountId ? { accountID: value.accountId } : {}), + ...(value.enterpriseUrl ? { enterpriseUrl: value.enterpriseUrl } : {}), + } + : undefined, + }) + const now = Date.now() + yield* tx.run(sql` + INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) + VALUES (${Credential.ID.create()}, ${integrationID}, 'default', ${JSON.stringify(credential)}, ${now}, ${now}) + `) + } + + if (!origins.length) return + const stored = yield* tx.get<{ value: string }>(sql`SELECT value FROM kv WHERE key = ${wellKnownSourcesKey}`) + const decoded = stored ? Option.getOrUndefined(decodeJson(stored.value)) : undefined + const current = Array.isArray(decoded) ? decoded.filter((item): item is string => typeof item === "string") : [] + const value = JSON.stringify(Array.from(new Set([...current, ...origins]))) + const now = Date.now() + yield* tx.run(sql` + INSERT INTO kv (key, value, time_created, time_updated) + VALUES (${wellKnownSourcesKey}, ${value}, ${now}, ${now}) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, time_updated = excluded.time_updated + `) + }) +} + +function methodID(integrationID: string) { + if (integrationID === "openai") return "chatgpt-browser" + if (["github-copilot", "opencode", "xai"].includes(integrationID)) return "device" + return "oauth" +} diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index d39919e8200..5a58432d22d 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -11,6 +11,7 @@ import { migrations } from "@opencode-ai/core/database/migration.gen" import { Database } from "@opencode-ai/core/database/database" import { tmpdir } from "./fixture/tmpdir" import type { SqlClient } from "effect/unstable/sql/SqlClient" +import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" const run = (effect: Effect.Effect) => Effect.runPromise( @@ -105,6 +106,64 @@ describe("DatabaseMigration", () => { ) }) + test("imports legacy JSON credentials without changing the source file or existing credentials", async () => { + await using tmp = await tmpdir() + const source = path.join(tmp.path, "auth.json") + const content = JSON.stringify({ + openai: { type: "oauth", refresh: "refresh", access: "access", expires: 123, accountId: "account" }, + anthropic: { type: "api", key: "legacy-key", metadata: { region: "us" } }, + "https://example.com/": { type: "wellknown", key: "TOKEN", token: "wellknown-key" }, + invalid: { type: "unknown" }, + }) + await Bun.write(source, content) + + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + const now = Date.now() + yield* db.run(sql` + INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) + VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now}) + `) + + yield* db.transaction((tx) => importLegacyCredentials(tx, source)) + + expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual( + [ + { + integration_id: "anthropic", + label: "Existing", + value: JSON.stringify({ type: "key", key: "current-key" }), + }, + { + integration_id: "https://example.com", + label: "default", + value: JSON.stringify({ type: "key", key: "wellknown-key" }), + }, + { + integration_id: "openai", + label: "default", + value: JSON.stringify({ + type: "oauth", + methodID: "chatgpt-browser", + refresh: "refresh", + access: "access", + expires: 123, + metadata: { accountID: "account" }, + }), + }, + ], + ) + expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'wellknown:sources'`)).toEqual({ + value: JSON.stringify(["https://example.com"]), + }) + }), + ) + + expect(await Bun.file(source).text()).toBe(content) + }) + test("rolls back a failed migration without recording it", async () => { await run( Effect.gen(function* () {