account.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import path from "path"
  2. import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
  3. import { Identifier } from "./util/identifier"
  4. import { NonNegativeInt, withStatics } from "./schema"
  5. import { Global } from "./global"
  6. import { AppFileSystem } from "./filesystem"
  7. import { EventV2 } from "./event"
  8. export const ID = Schema.String.pipe(
  9. Schema.brand("AccountV2.ID"),
  10. withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
  11. )
  12. export type ID = typeof ID.Type
  13. export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
  14. export type ServiceID = typeof ServiceID.Type
  15. export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
  16. type: Schema.Literal("oauth"),
  17. refresh: Schema.String,
  18. access: Schema.String,
  19. expires: NonNegativeInt,
  20. }) {}
  21. export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
  22. type: Schema.Literal("api"),
  23. key: Schema.String,
  24. metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
  25. }) {}
  26. export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
  27. .pipe(Schema.toTaggedUnion("type"))
  28. .annotate({
  29. identifier: "AccountV2.Credential",
  30. })
  31. export type Credential = Schema.Schema.Type<typeof Credential>
  32. export class Info extends Schema.Class<Info>("AccountV2.Info")({
  33. id: ID,
  34. serviceID: ServiceID,
  35. description: Schema.String,
  36. credential: Credential,
  37. }) {}
  38. export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
  39. operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
  40. cause: Schema.Defect,
  41. }) {}
  42. export type Error = FileWriteError
  43. export const Event = {
  44. Added: EventV2.define({
  45. type: "account.added",
  46. schema: {
  47. account: Info,
  48. },
  49. }),
  50. Removed: EventV2.define({
  51. type: "account.removed",
  52. schema: {
  53. account: Info,
  54. },
  55. }),
  56. Switched: EventV2.define({
  57. type: "account.switched",
  58. schema: {
  59. serviceID: ServiceID,
  60. from: Schema.optional(ID),
  61. to: Schema.optional(ID),
  62. },
  63. }),
  64. }
  65. interface Writable {
  66. version: 2
  67. accounts: Record<string, Info>
  68. active: Record<string, ID>
  69. }
  70. const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
  71. function migrate(old: Record<string, unknown>): Writable {
  72. const accounts: Record<string, Info> = {}
  73. const active: Record<string, ID> = {}
  74. for (const [serviceID, value] of Object.entries(old)) {
  75. const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
  76. const parsed = (decoded as Record<string, Credential>)[serviceID]
  77. if (!parsed) continue
  78. const id = Identifier.ascending()
  79. const account = ID.make(id)
  80. const brandedServiceID = ServiceID.make(serviceID)
  81. accounts[id] = new Info({
  82. id: account,
  83. serviceID: brandedServiceID,
  84. description: "default",
  85. credential: parsed,
  86. })
  87. active[brandedServiceID] = account
  88. }
  89. return { version: 2, accounts, active }
  90. }
  91. export interface Interface {
  92. readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
  93. readonly all: () => Effect.Effect<Info[], Error>
  94. readonly create: (input: {
  95. serviceID: ServiceID
  96. credential: Credential
  97. description?: string
  98. }) => Effect.Effect<Info | undefined, Error>
  99. readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
  100. readonly remove: (id: ID) => Effect.Effect<void, Error>
  101. readonly activate: (id: ID) => Effect.Effect<void, Error>
  102. readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
  103. readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
  104. }
  105. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
  106. export const layer = Layer.effect(
  107. Service,
  108. Effect.gen(function* () {
  109. const fsys = yield* AppFileSystem.Service
  110. const global = yield* Global.Service
  111. const events = yield* EventV2.Service
  112. const file = path.join(global.data, "account.json")
  113. const legacyFile = path.join(global.data, "auth.json")
  114. const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
  115. const migrated = migrate(raw)
  116. yield* fsys
  117. .writeJson(file, migrated, 0o600)
  118. .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
  119. return migrated
  120. })
  121. const parseAuthContent = () => {
  122. try {
  123. return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
  124. } catch {}
  125. }
  126. const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
  127. if (process.env.OPENCODE_AUTH_CONTENT) {
  128. const raw = parseAuthContent()
  129. if (raw && typeof raw === "object") {
  130. if ("version" in raw && raw.version === 2) return raw as Writable
  131. return yield* writeMigrated(raw as Record<string, unknown>)
  132. }
  133. return { version: 2, accounts: {}, active: {} }
  134. }
  135. const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
  136. if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
  137. const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
  138. if (raw && typeof raw === "object") {
  139. if ("version" in raw && raw.version === 2) return raw as Writable
  140. return yield* writeMigrated(raw as Record<string, unknown>)
  141. }
  142. return { version: 2, accounts: {}, active: {} }
  143. })
  144. const write = (data: Writable) =>
  145. fsys
  146. .writeJson(file, data, 0o600)
  147. .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
  148. const state = SynchronizedRef.makeUnsafe(
  149. yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
  150. )
  151. const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
  152. const data = yield* SynchronizedRef.get(state)
  153. const account = data.accounts[id]
  154. if (!account) return
  155. const activated = yield* SynchronizedRef.modifyEffect(
  156. state,
  157. Effect.fnUntraced(function* (data) {
  158. const nextAccount = data.accounts[id]
  159. if (!nextAccount) return [undefined, data] as const
  160. const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
  161. yield* write(next)
  162. return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
  163. }),
  164. )
  165. if (activated) yield* events.publish(Event.Switched, activated)
  166. })
  167. const result: Interface = {
  168. get: Effect.fn("AccountV2.get")(function* (id) {
  169. return (yield* SynchronizedRef.get(state)).accounts[id]
  170. }),
  171. all: Effect.fn("AccountV2.all")(function* () {
  172. return Object.values((yield* SynchronizedRef.get(state)).accounts)
  173. }),
  174. active: Effect.fn("AccountV2.active")(function* (serviceID) {
  175. const data = yield* SynchronizedRef.get(state)
  176. return (
  177. data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
  178. )
  179. }),
  180. forService: Effect.fn("AccountV2.list")(function* (serviceID) {
  181. return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
  182. }),
  183. create: Effect.fn("AccountV2.add")(function* (input) {
  184. const id = ID.make(Identifier.ascending())
  185. const account = new Info({
  186. id,
  187. serviceID: input.serviceID,
  188. description: input.description ?? "default",
  189. credential: input.credential,
  190. })
  191. const added = yield* SynchronizedRef.modifyEffect(
  192. state,
  193. Effect.fnUntraced(function* (data) {
  194. const next = {
  195. ...data,
  196. accounts: { ...data.accounts, [account.id]: account },
  197. active: { ...data.active, [account.serviceID]: account.id },
  198. }
  199. yield* write(next)
  200. return [
  201. {
  202. account,
  203. switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
  204. },
  205. next,
  206. ] as const
  207. }),
  208. )
  209. yield* events.publish(Event.Added, { account: added.account })
  210. yield* events.publish(Event.Switched, added.switched)
  211. return added.account
  212. }),
  213. update: Effect.fn("AccountV2.update")(function* (id, updates) {
  214. const existing = (yield* SynchronizedRef.get(state)).accounts[id]
  215. if (!existing) return
  216. yield* SynchronizedRef.modifyEffect(
  217. state,
  218. Effect.fnUntraced(function* (data) {
  219. if (!data.accounts[id]) return [undefined, data] as const
  220. const next = {
  221. ...data,
  222. accounts: {
  223. ...data.accounts,
  224. [id]: new Info({
  225. id,
  226. serviceID: existing.serviceID,
  227. description: updates.description ?? existing.description,
  228. credential: updates.credential ?? existing.credential,
  229. }),
  230. },
  231. }
  232. yield* write(next)
  233. return [undefined, next] as const
  234. }),
  235. )
  236. }),
  237. remove: Effect.fn("AccountV2.remove")(function* (id) {
  238. const removed = yield* SynchronizedRef.modifyEffect(
  239. state,
  240. Effect.fnUntraced(function* (data) {
  241. const accounts = { ...data.accounts }
  242. const active = { ...data.active }
  243. const removed = accounts[id]
  244. if (!removed) return [undefined, data] as const
  245. const wasActive = active[removed.serviceID] === id
  246. delete accounts[id]
  247. const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
  248. if (wasActive) {
  249. if (replacement) active[removed.serviceID] = replacement.id
  250. else delete active[removed.serviceID]
  251. }
  252. const next = { ...data, accounts, active }
  253. yield* write(next)
  254. return [
  255. {
  256. account: removed,
  257. switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
  258. },
  259. next,
  260. ] as const
  261. }),
  262. )
  263. if (removed) {
  264. yield* events.publish(Event.Removed, { account: removed.account })
  265. if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
  266. }
  267. }),
  268. activate,
  269. }
  270. return Service.of(result)
  271. }),
  272. )
  273. export const defaultLayer = layer.pipe(
  274. Layer.provide(AppFileSystem.defaultLayer),
  275. Layer.provide(Global.defaultLayer),
  276. Layer.provide(EventV2.defaultLayer),
  277. )
  278. export * as AccountV2 from "./account"