Skip to content

Commit ce5e452

Browse files
Apply PR #17227: refactor(provider): effectify ProviderAuthService
2 parents c616b77 + c45fbb3 commit ce5e452

10 files changed

Lines changed: 933 additions & 156 deletions

File tree

packages/opencode/EFFECT_MIGRATION_PLAN.md

Lines changed: 399 additions & 0 deletions
Large diffs are not rendered by default.

packages/opencode/src/auth/index.ts

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,27 @@
11
import { Effect } from "effect"
2-
import z from "zod"
32
import { runtime } from "@/effect/runtime"
4-
import * as S from "./service"
3+
import {
4+
Api as ApiSchema,
5+
AuthService,
6+
type AuthServiceError,
7+
Info as InfoSchema,
8+
Oauth as OauthSchema,
9+
WellKnown as WellKnownSchema,
10+
type Info as AuthInfo,
11+
} from "./service"
512

613
export { OAUTH_DUMMY_KEY } from "./service"
714

8-
function runPromise<A>(f: (service: S.AuthService.Service) => Effect.Effect<A, S.AuthServiceError>) {
9-
return runtime.runPromise(S.AuthService.use(f))
15+
function runPromise<A>(f: (service: AuthService.Service) => Effect.Effect<A, AuthServiceError>) {
16+
return runtime.runPromise(AuthService.use(f))
1017
}
1118

1219
export namespace Auth {
13-
export const Oauth = z
14-
.object({
15-
type: z.literal("oauth"),
16-
refresh: z.string(),
17-
access: z.string(),
18-
expires: z.number(),
19-
accountId: z.string().optional(),
20-
enterpriseUrl: z.string().optional(),
21-
})
22-
.meta({ ref: "OAuth" })
23-
24-
export const Api = z
25-
.object({
26-
type: z.literal("api"),
27-
key: z.string(),
28-
})
29-
.meta({ ref: "ApiAuth" })
30-
31-
export const WellKnown = z
32-
.object({
33-
type: z.literal("wellknown"),
34-
key: z.string(),
35-
token: z.string(),
36-
})
37-
.meta({ ref: "WellKnownAuth" })
38-
39-
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
40-
export type Info = z.infer<typeof Info>
20+
export const Oauth = OauthSchema
21+
export const Api = ApiSchema
22+
export const WellKnown = WellKnownSchema
23+
export const Info = InfoSchema
24+
export type Info = AuthInfo
4125

4226
export async function get(providerID: string) {
4327
return runPromise((service) => service.get(providerID))

packages/opencode/src/auth/service.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,39 @@
11
import path from "path"
2-
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
2+
import { Effect, Layer, Schema, ServiceMap } from "effect"
3+
import z from "zod"
34
import { Global } from "../global"
45
import { Filesystem } from "../util/filesystem"
56

67
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
78

8-
export class Oauth extends Schema.Class<Oauth>("OAuth")({
9-
type: Schema.Literal("oauth"),
10-
refresh: Schema.String,
11-
access: Schema.String,
12-
expires: Schema.Number,
13-
accountId: Schema.optional(Schema.String),
14-
enterpriseUrl: Schema.optional(Schema.String),
15-
}) {}
16-
17-
export class Api extends Schema.Class<Api>("ApiAuth")({
18-
type: Schema.Literal("api"),
19-
key: Schema.String,
20-
}) {}
21-
22-
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
23-
type: Schema.Literal("wellknown"),
24-
key: Schema.String,
25-
token: Schema.String,
26-
}) {}
27-
28-
export const Info = Schema.Union([Oauth, Api, WellKnown])
29-
export type Info = Schema.Schema.Type<typeof Info>
9+
export const Oauth = z
10+
.object({
11+
type: z.literal("oauth"),
12+
refresh: z.string(),
13+
access: z.string(),
14+
expires: z.number(),
15+
accountId: z.string().optional(),
16+
enterpriseUrl: z.string().optional(),
17+
})
18+
.meta({ ref: "OAuth" })
19+
20+
export const Api = z
21+
.object({
22+
type: z.literal("api"),
23+
key: z.string(),
24+
})
25+
.meta({ ref: "ApiAuth" })
26+
27+
export const WellKnown = z
28+
.object({
29+
type: z.literal("wellknown"),
30+
key: z.string(),
31+
token: z.string(),
32+
})
33+
.meta({ ref: "WellKnownAuth" })
34+
35+
export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).meta({ ref: "Auth" })
36+
export type Info = z.infer<typeof Info>
3037

3138
export class AuthServiceError extends Schema.TaggedErrorClass<AuthServiceError>()("AuthServiceError", {
3239
message: Schema.String,
@@ -50,13 +57,19 @@ export class AuthService extends ServiceMap.Service<AuthService, AuthService.Ser
5057
static readonly layer = Layer.effect(
5158
AuthService,
5259
Effect.gen(function* () {
53-
const decode = Schema.decodeUnknownOption(Info)
54-
5560
const all = Effect.fn("AuthService.all")(() =>
5661
Effect.tryPromise({
5762
try: async () => {
5863
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
59-
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
64+
return Object.entries(data).reduce(
65+
(acc, [key, value]) => {
66+
const parsed = Info.safeParse(value)
67+
if (!parsed.success) return acc
68+
acc[key] = parsed.data
69+
return acc
70+
},
71+
{} as Record<string, Info>,
72+
)
6073
},
6174
catch: fail("Failed to read auth data"),
6275
}),

packages/opencode/src/project/instance.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { Effect } from "effect"
12
import { Log } from "@/util/log"
23
import { Context } from "../util/context"
34
import { Project } from "./project"
45
import { State } from "./state"
56
import { iife } from "@/util/iife"
67
import { GlobalBus } from "@/bus/global"
78
import { Filesystem } from "@/util/filesystem"
9+
import { InstanceState } from "@/util/instance-state"
810

911
interface Context {
1012
directory: string
@@ -106,15 +108,15 @@ export const Instance = {
106108
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
107109
const directory = Filesystem.resolve(input.directory)
108110
Log.Default.info("reloading instance", { directory })
109-
await State.dispose(directory)
111+
await Promise.all([State.dispose(directory), Effect.runPromise(InstanceState.dispose(directory))])
110112
cache.delete(directory)
111113
const next = track(directory, boot({ ...input, directory }))
112114
emit(directory)
113115
return await next
114116
},
115117
async dispose() {
116118
Log.Default.info("disposing instance", { directory: Instance.directory })
117-
await State.dispose(Instance.directory)
119+
await Promise.all([State.dispose(Instance.directory), Effect.runPromise(InstanceState.dispose(Instance.directory))])
118120
cache.delete(Instance.directory)
119121
emit(Instance.directory)
120122
},
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
2+
import { Instance } from "@/project/instance"
3+
import { Plugin } from "../plugin"
4+
import { filter, fromEntries, map, pipe } from "remeda"
5+
import type { AuthOuathResult } from "@opencode-ai/plugin"
6+
import { NamedError } from "@opencode-ai/util/error"
7+
import * as Auth from "@/auth/service"
8+
import { InstanceState } from "@/util/instance-state"
9+
import { ProviderID } from "./schema"
10+
import z from "zod"
11+
12+
export type Method = {
13+
type: "oauth" | "api"
14+
label: string
15+
}
16+
17+
export type Authorization = {
18+
url: string
19+
method: "auto" | "code"
20+
instructions: string
21+
}
22+
23+
export const OauthMissing = NamedError.create(
24+
"ProviderAuthOauthMissing",
25+
z.object({
26+
providerID: ProviderID.zod,
27+
}),
28+
)
29+
30+
export const OauthCodeMissing = NamedError.create(
31+
"ProviderAuthOauthCodeMissing",
32+
z.object({
33+
providerID: ProviderID.zod,
34+
}),
35+
)
36+
37+
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
38+
39+
export type ProviderAuthError =
40+
| Auth.AuthServiceError
41+
| InstanceType<typeof OauthMissing>
42+
| InstanceType<typeof OauthCodeMissing>
43+
| InstanceType<typeof OauthCallbackFailed>
44+
45+
export namespace ProviderAuthService {
46+
export interface Service {
47+
/** Get available auth methods for each provider (e.g. OAuth, API key). */
48+
readonly methods: () => Effect.Effect<Record<string, Method[]>>
49+
50+
/** Start an OAuth authorization flow for a provider. Returns the URL to redirect to. */
51+
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
52+
53+
/** Complete an OAuth flow after the user has authorized. Exchanges the code/callback for credentials. */
54+
readonly callback: (input: {
55+
providerID: ProviderID
56+
method: number
57+
code?: string
58+
}) => Effect.Effect<void, ProviderAuthError>
59+
60+
/** Set an API key directly for a provider (no OAuth flow). */
61+
readonly api: (input: { providerID: ProviderID; key: string }) => Effect.Effect<void, Auth.AuthServiceError>
62+
}
63+
}
64+
65+
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()(
66+
"@opencode/ProviderAuth",
67+
) {
68+
static readonly layer = Layer.effect(
69+
ProviderAuthService,
70+
Effect.gen(function* () {
71+
const auth = yield* Auth.AuthService
72+
const state = yield* InstanceState.make({
73+
lookup: () =>
74+
Effect.promise(async () => {
75+
const methods = pipe(
76+
await Plugin.list(),
77+
filter((x) => x.auth?.provider !== undefined),
78+
map((x) => [x.auth!.provider, x.auth!] as const),
79+
fromEntries(),
80+
)
81+
return { methods, pending: new Map<string, AuthOuathResult>() }
82+
}),
83+
})
84+
85+
const methods = Effect.fn("ProviderAuthService.methods")(function* () {
86+
const x = yield* InstanceState.get(state)
87+
return Record.map(x.methods, (y) => y.methods.map((z): Method => Struct.pick(z, ["type", "label"])))
88+
})
89+
90+
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: {
91+
providerID: ProviderID
92+
method: number
93+
}) {
94+
const authHook = (yield* InstanceState.get(state)).methods[input.providerID]
95+
const method = authHook.methods[input.method]
96+
if (method.type !== "oauth") return
97+
const result = yield* Effect.promise(() => method.authorize())
98+
99+
const s = yield* InstanceState.get(state)
100+
s.pending.set(input.providerID, result)
101+
return {
102+
url: result.url,
103+
method: result.method,
104+
instructions: result.instructions,
105+
}
106+
})
107+
108+
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: {
109+
providerID: ProviderID
110+
method: number
111+
code?: string
112+
}) {
113+
const match = (yield* InstanceState.get(state)).pending.get(input.providerID)
114+
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
115+
116+
if (match.method === "code" && !input.code)
117+
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
118+
119+
const result = yield* Effect.promise(() =>
120+
match.method === "code" ? match.callback(input.code!) : match.callback(),
121+
)
122+
123+
if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({}))
124+
125+
if ("key" in result) {
126+
yield* auth.set(input.providerID, {
127+
type: "api",
128+
key: result.key,
129+
})
130+
}
131+
132+
if ("refresh" in result) {
133+
yield* auth.set(input.providerID, {
134+
type: "oauth",
135+
access: result.access,
136+
refresh: result.refresh,
137+
expires: result.expires,
138+
...(result.accountId ? { accountId: result.accountId } : {}),
139+
})
140+
}
141+
})
142+
143+
const api = Effect.fn("ProviderAuthService.api")(function* (input: { providerID: ProviderID; key: string }) {
144+
yield* auth.set(input.providerID, {
145+
type: "api",
146+
key: input.key,
147+
})
148+
})
149+
150+
return ProviderAuthService.of({
151+
methods,
152+
authorize,
153+
callback,
154+
api,
155+
})
156+
}),
157+
)
158+
159+
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthService.defaultLayer))
160+
}

0 commit comments

Comments
 (0)