diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 80253a665e9..79e9e615d21 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,9 +1,13 @@ -import path from "path" -import { Global } from "../global" +import { Effect } from "effect" import z from "zod" -import { Filesystem } from "../util/filesystem" +import { runtime } from "@/effect/runtime" +import * as S from "./service" -export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" +export { OAUTH_DUMMY_KEY } from "./service" + +function runPromise(f: (service: S.AuthService.Service) => Effect.Effect) { + return runtime.runPromise(S.AuthService.use(f)) +} export namespace Auth { export const Oauth = z @@ -35,39 +39,19 @@ export namespace Auth { export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" }) export type Info = z.infer - const filepath = path.join(Global.Path.data, "auth.json") - export async function get(providerID: string) { - const auth = await all() - return auth[providerID] + return runPromise((service) => service.get(providerID)) } export async function all(): Promise> { - const data = await Filesystem.readJson>(filepath).catch(() => ({})) - return Object.entries(data).reduce( - (acc, [key, value]) => { - const parsed = Info.safeParse(value) - if (!parsed.success) return acc - acc[key] = parsed.data - return acc - }, - {} as Record, - ) + return runPromise((service) => service.all()) } export async function set(key: string, info: Info) { - const normalized = key.replace(/\/+$/, "") - const data = await all() - if (normalized !== key) delete data[key] - delete data[normalized + "/"] - await Filesystem.writeJson(filepath, { ...data, [normalized]: info }, 0o600) + return runPromise((service) => service.set(key, info)) } export async function remove(key: string) { - const normalized = key.replace(/\/+$/, "") - const data = await all() - delete data[key] - delete data[normalized] - await Filesystem.writeJson(filepath, data, 0o600) + return runPromise((service) => service.remove(key)) } } diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/service.ts new file mode 100644 index 00000000000..100a132b873 --- /dev/null +++ b/packages/opencode/src/auth/service.ts @@ -0,0 +1,101 @@ +import path from "path" +import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect" +import { Global } from "../global" +import { Filesystem } from "../util/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +export class Oauth extends Schema.Class("OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: Schema.Number, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) {} + +export class Api extends Schema.Class("ApiAuth")({ + type: Schema.Literal("api"), + key: Schema.String, +}) {} + +export class WellKnown extends Schema.Class("WellKnownAuth")({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) {} + +export const Info = Schema.Union([Oauth, Api, WellKnown]) +export type Info = Schema.Schema.Type + +export class AuthServiceError extends Schema.TaggedErrorClass()("AuthServiceError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +const file = path.join(Global.Path.data, "auth.json") + +const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause }) + +export namespace AuthService { + export interface Service { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthServiceError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect + } +} + +export class AuthService extends ServiceMap.Service()("@opencode/Auth") { + static readonly layer = Layer.effect( + AuthService, + Effect.gen(function* () { + const decode = Schema.decodeUnknownOption(Info) + + const all = Effect.fn("AuthService.all")(() => + Effect.tryPromise({ + try: async () => { + const data = await Filesystem.readJson>(file).catch(() => ({})) + return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) + }, + catch: fail("Failed to read auth data"), + }), + ) + + const get = Effect.fn("AuthService.get")(function* (providerID: string) { + return (yield* all())[providerID] + }) + + const set = Effect.fn("AuthService.set")(function* (key: string, info: Info) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + if (norm !== key) delete data[key] + delete data[norm + "/"] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + const remove = Effect.fn("AuthService.remove")(function* (key: string) { + const norm = key.replace(/\/+$/, "") + const data = yield* all() + delete data[key] + delete data[norm] + yield* Effect.tryPromise({ + try: () => Filesystem.writeJson(file, data, 0o600), + catch: fail("Failed to write auth data"), + }) + }) + + return AuthService.of({ + get, + all, + set, + remove, + }) + }), + ) + + static readonly defaultLayer = AuthService.layer +} diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 1868b38a015..23acff73379 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,4 +1,5 @@ -import { ManagedRuntime } from "effect" +import { Layer, ManagedRuntime } from "effect" import { AccountService } from "@/account/service" +import { AuthService } from "@/auth/service" -export const runtime = ManagedRuntime.make(AccountService.defaultLayer) +export const runtime = ManagedRuntime.make(Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer))