@harars/opencode-switch-openai-auth-plugin 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts DELETED
@@ -1,244 +0,0 @@
1
- import fs from "node:fs/promises"
2
- import path from "node:path"
3
- import type { OpenAIAuth, StoreEntry, StoreFile, StoreLoad, StoredAccount } from "./types"
4
- import { authPath, storeDir, storePath } from "./paths"
5
- import { clean, entryFromAuth, extractAccountId, extractEmail, fallback } from "./parse"
6
-
7
- function obj(v: unknown) {
8
- return v && typeof v === "object" ? (v as Record<string, unknown>) : undefined
9
- }
10
-
11
- function text(v: unknown) {
12
- return typeof v === "string" && v.trim() ? v : undefined
13
- }
14
-
15
- function num(v: unknown) {
16
- return typeof v === "number" && Number.isFinite(v) ? v : undefined
17
- }
18
-
19
- export function validAuth(v: unknown): v is OpenAIAuth {
20
- const item = obj(v)
21
- if (!item) return false
22
- if (item.type !== "oauth") return false
23
- if (!text(item.refresh) || !text(item.access) || num(item.expires) === undefined) return false
24
- if (item.accountId !== undefined && !text(item.accountId)) return false
25
- if (item.enterpriseUrl !== undefined && !text(item.enterpriseUrl)) return false
26
- return true
27
- }
28
-
29
- function validEntry(id: string, v: unknown): v is StoreEntry {
30
- const item = obj(v)
31
- if (!item || item.key !== id) return false
32
- if (item.email !== undefined && !text(item.email)) return false
33
- if (item.accountId !== undefined && !text(item.accountId)) return false
34
- if (item.plan !== undefined && !text(item.plan)) return false
35
- if (num(item.savedAt) === undefined) return false
36
- if (item.lastUsedAt !== undefined && num(item.lastUsedAt) === undefined) return false
37
- return validAuth(item.auth)
38
- }
39
-
40
- async function readJson(file: string) {
41
- const data = Bun.file(file)
42
- if (!(await data.exists())) return
43
- const raw = (await data.text()).trim()
44
- if (!raw) return
45
- return JSON.parse(raw) as unknown
46
- }
47
-
48
- async function writeJson(file: string, value: unknown) {
49
- await fs.mkdir(storeDir(), { recursive: true })
50
- await Bun.write(file, `${JSON.stringify(value, null, 2)}\n`)
51
- }
52
-
53
- function hydrateAccount(id: string, item: StoreEntry): StoredAccount {
54
- return {
55
- ...item,
56
- id,
57
- email: item.email || extractEmail(item.auth),
58
- accountId: item.accountId || extractAccountId(item.auth),
59
- }
60
- }
61
-
62
- function normalizeEntries(entries: Record<string, unknown>) {
63
- const normalized: Record<string, StoreEntry> = {}
64
- let changed = false
65
-
66
- for (const [id, value] of Object.entries(entries)) {
67
- if (!validEntry(id, value)) {
68
- const item = obj(value)
69
- if (!item || !validAuth(item.auth)) {
70
- changed = true
71
- continue
72
- }
73
- const auth = clean(item.auth)
74
- const nextKey = fallback(auth.refresh)
75
- normalized[nextKey] = {
76
- key: nextKey,
77
- email: text(item.email) || extractEmail(auth),
78
- accountId: text(item.accountId) || extractAccountId(auth),
79
- plan: text(item.plan),
80
- savedAt: num(item.savedAt) ?? Date.now(),
81
- ...(num(item.lastUsedAt) !== undefined ? { lastUsedAt: num(item.lastUsedAt) } : {}),
82
- auth,
83
- }
84
- changed = true
85
- continue
86
- }
87
-
88
- const nextKey = fallback(value.auth.refresh)
89
- if (nextKey !== id || value.key !== nextKey) changed = true
90
- normalized[nextKey] = {
91
- ...value,
92
- key: nextKey,
93
- auth: clean(value.auth),
94
- }
95
- }
96
-
97
- return { normalized, changed }
98
- }
99
-
100
- export async function readCurrentAuth() {
101
- const raw = await readJson(authPath())
102
- const data = obj(raw)
103
- if (!data) return
104
- const auth = data.openai
105
- if (!validAuth(auth)) return
106
- return clean(auth)
107
- }
108
-
109
- export async function writeCurrentOpenAI(auth: OpenAIAuth) {
110
- const raw = (await readJson(authPath())) ?? {}
111
- const next = obj(raw) ?? {}
112
- next.openai = clean(auth)
113
- await fs.mkdir(path.dirname(authPath()), { recursive: true })
114
- await Bun.write(authPath(), `${JSON.stringify(next, null, 2)}\n`)
115
- }
116
-
117
- export async function readStore(): Promise<StoreLoad> {
118
- const raw = await readJson(storePath())
119
- if (raw === undefined) return { ok: false, reason: "missing" }
120
- const root = obj(raw)
121
- const map = obj(root?.openai)
122
- if (!root || root.version !== 1 || !map) return { ok: false, reason: "malformed" }
123
- const { normalized, changed } = normalizeEntries(map)
124
- if (changed) {
125
- await writeStore({ version: 1, openai: normalized })
126
- }
127
- return {
128
- ok: true,
129
- store: {
130
- version: 1,
131
- openai: normalized,
132
- },
133
- }
134
- }
135
-
136
- export async function writeStore(store: StoreFile) {
137
- await writeJson(storePath(), store)
138
- }
139
-
140
- export async function readAllAccounts() {
141
- const load = await readStore()
142
- if (!load.ok) return load
143
- return {
144
- ok: true as const,
145
- accounts: Object.entries(load.store.openai)
146
- .filter(([id, item]) => validEntry(id, item))
147
- .map(([id, item]) => hydrateAccount(id, item)),
148
- }
149
- }
150
-
151
- export async function pruneInvalidAccounts() {
152
- const load = await readStore()
153
- if (!load.ok) return load
154
- const next = Object.fromEntries(Object.entries(load.store.openai).filter(([id, item]) => validEntry(id, item)))
155
- if (Object.keys(next).length !== Object.keys(load.store.openai).length) {
156
- await writeStore({ version: 1, openai: next })
157
- }
158
- return {
159
- ok: true as const,
160
- accounts: Object.entries(next).map(([id, item]) => hydrateAccount(id, item)),
161
- }
162
- }
163
-
164
- export async function currentAccountId() {
165
- const auth = await readCurrentAuth()
166
- if (!auth) return
167
- return fallback(auth.refresh)
168
- }
169
-
170
- function match(list: StoredAccount[], auth: OpenAIAuth) {
171
- return list.find((item) => {
172
- if (item.auth.refresh === auth.refresh) return true
173
- return item.id === fallback(auth.refresh)
174
- })?.id
175
- }
176
-
177
- export async function upsertSavedAccount(auth: OpenAIAuth) {
178
- const base = entryFromAuth(auth)
179
- const load = await readStore()
180
- const store = load.ok ? load.store : { version: 1 as const, openai: {} }
181
- const list = Object.entries(store.openai)
182
- const match = list.find(([id, item]) => {
183
- if (!validEntry(id, item)) return false
184
- return item.auth.refresh === auth.refresh || id === base.key
185
- })
186
- const prev = match?.[1]
187
- const next: StoreEntry = {
188
- ...base,
189
- savedAt: prev?.savedAt ?? base.savedAt,
190
- lastUsedAt: prev?.lastUsedAt,
191
- }
192
- const out = Object.fromEntries(
193
- list.filter(([id]) => id !== match?.[0]).filter(([id, item]) => validEntry(id, item)),
194
- ) as Record<string, StoreEntry>
195
- out[next.key] = next
196
- await writeStore({ version: 1, openai: out })
197
- return { ...next, id: next.key }
198
- }
199
-
200
- function sort(list: StoredAccount[], cur?: string) {
201
- return [...list].sort((a, b) => {
202
- const ac = a.id === cur ? 1 : 0
203
- const bc = b.id === cur ? 1 : 0
204
- if (ac !== bc) return bc - ac
205
- if ((b.lastUsedAt ?? 0) !== (a.lastUsedAt ?? 0)) return (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0)
206
- if (b.savedAt !== a.savedAt) return b.savedAt - a.savedAt
207
- return (a.email || a.accountId || a.id).localeCompare(b.email || b.accountId || b.id)
208
- })
209
- }
210
-
211
- export async function listAccounts() {
212
- const load = await pruneInvalidAccounts()
213
- if (!load.ok) return load
214
- const auth = await readCurrentAuth()
215
- const cur = auth ? match(load.accounts, auth) ?? fallback(auth.refresh) : undefined
216
- return { ok: true as const, accounts: sort(load.accounts, cur), current: cur }
217
- }
218
-
219
- export async function switchAccount(id: string) {
220
- const load = await readStore()
221
- if (!load.ok) throw new Error("account store unavailable")
222
- const item = load.store.openai[id]
223
- if (!validEntry(id, item)) throw new Error("saved account not found")
224
- await writeCurrentOpenAI(item.auth)
225
- await writeStore({
226
- version: 1,
227
- openai: {
228
- ...load.store.openai,
229
- [id]: {
230
- ...item,
231
- lastUsedAt: Date.now(),
232
- },
233
- },
234
- })
235
- }
236
-
237
- export async function deleteSavedAccount(id: string) {
238
- const load = await readStore()
239
- if (!load.ok) throw new Error("account store unavailable")
240
- if (!(id in load.store.openai)) throw new Error("saved account not found")
241
- const next = { ...load.store.openai }
242
- delete next[id]
243
- await writeStore({ version: 1, openai: next })
244
- }
package/src/tui.tsx DELETED
@@ -1,194 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import type { TuiCommand, TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
- import { accountDescription, accountFooter, accountTitle, loginTitle, logoutTitle } from "./format"
4
- import { hasLogin, loginOpenAI } from "./login"
5
- import { deleteSavedAccount, listAccounts, switchAccount } from "./store"
6
- import type { StoredAccount, SwitchAction, SwitchOption } from "./types"
7
-
8
- let seq = 0
9
-
10
- function frame(text: string) {
11
- return (
12
- <box paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={2}>
13
- <text>{text}</text>
14
- </box>
15
- )
16
- }
17
-
18
- async function pick(api: Parameters<TuiPlugin>[0], list: StoredAccount[]) {
19
- return await new Promise<string | null>((resolve) => {
20
- api.ui.dialog.replace(
21
- () => (
22
- <api.ui.DialogSelect
23
- title="Remove saved account"
24
- options={list.map((item) => ({
25
- title: accountTitle(item),
26
- value: item.id,
27
- category: accountDescription(item, false),
28
- }))}
29
- onSelect={(item) => resolve(item.value)}
30
- />
31
- ),
32
- () => resolve(null),
33
- )
34
- })
35
- }
36
-
37
- async function confirm(api: Parameters<TuiPlugin>[0], title: string, message: string) {
38
- return await new Promise<boolean>((resolve) => {
39
- api.ui.dialog.replace(
40
- () => (
41
- <api.ui.DialogConfirm
42
- title={title}
43
- message={message}
44
- onConfirm={() => resolve(true)}
45
- onCancel={() => resolve(false)}
46
- />
47
- ),
48
- () => resolve(false),
49
- )
50
- })
51
- }
52
-
53
- async function remove(api: Parameters<TuiPlugin>[0], list: StoredAccount[]) {
54
- const id = await pick(api, list)
55
- if (!id) return
56
- const item = list.find((row) => row.id === id)
57
- if (!item) return
58
- const ok = await confirm(api, "Remove saved account", `Remove ${accountTitle(item)} from saved accounts?`)
59
- if (!ok) return
60
- try {
61
- await deleteSavedAccount(id)
62
- api.ui.toast({ variant: "success", message: "Saved account removed" })
63
- } catch {
64
- api.ui.toast({ variant: "error", message: "Failed to remove saved account" })
65
- }
66
- }
67
-
68
- function options(list: StoredAccount[], current: string | undefined, canLogin: boolean): SwitchOption[] {
69
- const rows = list.map((item) => ({
70
- title: accountTitle(item),
71
- value: { type: "account", id: item.id } as SwitchAction,
72
- description: accountDescription(item, item.id === current),
73
- footer: accountFooter(item.id === current),
74
- category: "Accounts",
75
- }))
76
- return [
77
- ...(canLogin ? [{ title: loginTitle(), value: { type: "login" } as SwitchAction }] : []),
78
- ...rows,
79
- ...(list.length
80
- ? [
81
- {
82
- title: logoutTitle(true),
83
- value: { type: "logout" } as SwitchAction,
84
- description: "Remove a saved account",
85
- category: "Actions",
86
- },
87
- ]
88
- : []),
89
- ]
90
- }
91
-
92
- async function open(api: Parameters<TuiPlugin>[0]) {
93
- const id = ++seq
94
- api.ui.dialog.setSize("large")
95
- api.ui.dialog.replace(() => frame("Loading accounts..."))
96
-
97
- const [store, canLogin] = await Promise.all([listAccounts(), hasLogin(api)])
98
- if (id !== seq) return
99
-
100
- if (!store.ok && store.reason === "malformed") {
101
- api.ui.toast({ variant: "error", message: "Failed to load account store" })
102
- }
103
-
104
- const accounts = store.ok ? store.accounts : []
105
- const current = store.ok ? store.current : undefined
106
- const rows = options(accounts, current, canLogin)
107
-
108
- if (!rows.length) {
109
- api.ui.dialog.replace(() => frame("OpenAI login unavailable and no saved accounts found"))
110
- return
111
- }
112
-
113
- if (!accounts.length && canLogin) {
114
- api.ui.dialog.replace(() => (
115
- <api.ui.DialogSelect
116
- title="Switch OpenAI account"
117
- options={rows}
118
- placeholder="Search"
119
- onSelect={(item) => void act(api, item.value, accounts)}
120
- />
121
- ))
122
- return
123
- }
124
-
125
- api.ui.dialog.replace(() => (
126
- <api.ui.DialogSelect
127
- title="Switch OpenAI account"
128
- options={rows}
129
- placeholder="Search"
130
- current={current ? ({ type: "account", id: current } as SwitchAction) : undefined}
131
- onSelect={(item) => void act(api, item.value, accounts)}
132
- />
133
- ))
134
- }
135
-
136
- async function act(api: Parameters<TuiPlugin>[0], action: SwitchAction, list: StoredAccount[]) {
137
- if (action.type === "login") {
138
- const ok = await loginOpenAI(api)
139
- if (ok) {
140
- api.ui.toast({ variant: "success", message: "Account saved" })
141
- await open(api)
142
- }
143
- return
144
- }
145
- if (action.type === "logout") {
146
- await remove(api, list)
147
- await open(api)
148
- return
149
- }
150
- try {
151
- await switchAccount(action.id)
152
- } catch {
153
- api.ui.toast({ variant: "error", message: "Failed to switch account" })
154
- return
155
- }
156
-
157
- try {
158
- const client = api.client as typeof api.client & {
159
- sync?: { bootstrap?: () => Promise<void> }
160
- }
161
- if (typeof api.client.instance.dispose === "function") {
162
- await api.client.instance.dispose()
163
- }
164
- if (typeof client.sync?.bootstrap === "function") {
165
- await client.sync.bootstrap()
166
- }
167
- api.ui.toast({ variant: "success", message: "Switched OpenAI account" })
168
- api.ui.dialog.clear()
169
- } catch {
170
- api.ui.toast({
171
- variant: "warning",
172
- message: "Account file was switched, but session refresh failed",
173
- })
174
- }
175
- }
176
-
177
- const tui: TuiPlugin = async (api) => {
178
- api.command.register(() => [
179
- {
180
- title: "Switch OpenAI account",
181
- value: "openai.switch",
182
- description: "Login, switch, or remove saved OpenAI accounts",
183
- slash: { name: "switch" },
184
- onSelect: () => {
185
- void open(api)
186
- },
187
- } satisfies TuiCommand,
188
- ])
189
- }
190
-
191
- export default {
192
- id: "harars.switch-auth",
193
- tui,
194
- } satisfies TuiPluginModule & { id: string }
package/src/types.ts DELETED
@@ -1,71 +0,0 @@
1
- export type OpenAIAuth = {
2
- type: "oauth"
3
- refresh: string
4
- access: string
5
- expires: number
6
- accountId?: string
7
- enterpriseUrl?: string
8
- }
9
-
10
- export type StoreEntry = {
11
- key: string
12
- email?: string
13
- accountId?: string
14
- plan?: string
15
- savedAt: number
16
- lastUsedAt?: number
17
- auth: OpenAIAuth
18
- }
19
-
20
- export type StoreFile = {
21
- version: 1
22
- openai: Record<string, StoreEntry>
23
- }
24
-
25
- export type StoredAccount = StoreEntry & {
26
- id: string
27
- }
28
-
29
- export type SwitchAction =
30
- | { type: "login" }
31
- | { type: "account"; id: string }
32
- | { type: "logout" }
33
-
34
- export type SwitchOption = {
35
- title: string
36
- value: SwitchAction
37
- description?: string
38
- category?: string
39
- footer?: string
40
- }
41
-
42
- export type Claims = Record<string, unknown>
43
-
44
- export type StoreLoad =
45
- | { ok: true; store: StoreFile }
46
- | { ok: false; reason: "malformed" | "missing" }
47
-
48
- export type ProviderPrompt = {
49
- type: "text" | "select"
50
- key: string
51
- message: string
52
- placeholder?: string
53
- options?: Array<{ label: string; value: string; hint?: string }>
54
- when?: {
55
- key: string
56
- op: "eq" | "neq"
57
- value: string
58
- }
59
- }
60
-
61
- export type ProviderMethod = {
62
- type: "oauth" | "api"
63
- label: string
64
- prompts?: ProviderPrompt[]
65
- }
66
-
67
- export type OAuthAuthz = {
68
- url: string
69
- method: "auto" | "code"
70
- instructions: string
71
- }