@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harars/opencode-switch-openai-auth-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenCode TUI plugin for switching saved OpenAI OAuth accounts",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,10 +21,10 @@
21
21
  ],
22
22
  "type": "module",
23
23
  "license": "MIT",
24
- "main": "./src/index.ts",
24
+ "main": "./dist/index.js",
25
25
  "exports": {
26
- ".": "./src/index.ts",
27
- "./tui": "./src/tui.tsx"
26
+ ".": "./dist/index.js",
27
+ "./tui": "./dist/tui.js"
28
28
  },
29
29
  "engines": {
30
30
  "opencode": "^1.0.0"
@@ -34,11 +34,12 @@
34
34
  }]],
35
35
  "scripts": {
36
36
  "build": "bun ./scripts/build.ts",
37
+ "prepack": "bun run build",
37
38
  "typecheck": "bunx tsc -p tsconfig.json --noEmit",
38
39
  "test": "bun test"
39
40
  },
40
41
  "files": [
41
- "src",
42
+ "dist",
42
43
  "README.md",
43
44
  "LICENSE"
44
45
  ],
package/src/format.ts DELETED
@@ -1,27 +0,0 @@
1
- import type { StoredAccount } from "./types"
2
-
3
- function short(id?: string) {
4
- if (!id) return
5
- if (id.length <= 12) return id
6
- return `${id.slice(0, 6)}...${id.slice(-4)}`
7
- }
8
-
9
- export function accountTitle(account: StoredAccount) {
10
- return account.email || account.accountId || account.id
11
- }
12
-
13
- export function accountDescription(account: StoredAccount, current: boolean) {
14
- return short(account.accountId)
15
- }
16
-
17
- export function accountFooter(current: boolean) {
18
- return current ? "Current" : undefined
19
- }
20
-
21
- export function loginTitle() {
22
- return "login"
23
- }
24
-
25
- export function logoutTitle(has: boolean) {
26
- return has ? "logout" : "logout unavailable"
27
- }
package/src/index.ts DELETED
@@ -1 +0,0 @@
1
- export { default } from "./tui"
package/src/login.tsx DELETED
@@ -1,297 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import { TextAttributes } from "@opentui/core"
3
- import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
4
- import { useKeyboard } from "@opentui/solid"
5
- import type { OAuthAuthz, ProviderMethod, ProviderPrompt } from "./types"
6
- import { readCurrentAuth, upsertSavedAccount } from "./store"
7
-
8
- type OAuthMethod = {
9
- index: number
10
- method: ProviderMethod
11
- }
12
-
13
- function visible(prompt: ProviderPrompt, values: Record<string, string>) {
14
- if (!prompt.when) return true
15
- const cur = values[prompt.when.key]
16
- if (prompt.when.op === "eq") return cur === prompt.when.value
17
- return cur !== prompt.when.value
18
- }
19
-
20
- function same(prev: Awaited<ReturnType<typeof readCurrentAuth>>, next: Awaited<ReturnType<typeof readCurrentAuth>>) {
21
- if (!prev || !next) return false
22
- return prev.refresh === next.refresh
23
- }
24
-
25
- function clip(text: string) {
26
- if (process.stdout.isTTY) {
27
- const base64 = Buffer.from(text).toString("base64")
28
- const osc52 = `\x1b]52;c;${base64}\x07`
29
- const seq = process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
30
- process.stdout.write(seq)
31
- }
32
-
33
- const cmds = process.platform === "darwin"
34
- ? [["pbcopy"]]
35
- : process.platform === "win32"
36
- ? [["clip"]]
37
- : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
38
-
39
- return Promise.any(
40
- cmds.map(async (cmd) => {
41
- const proc = Bun.spawn({
42
- cmd,
43
- stdin: "pipe",
44
- stdout: "ignore",
45
- stderr: "ignore",
46
- })
47
- await proc.stdin.write(new TextEncoder().encode(text))
48
- proc.stdin.end()
49
- const code = await proc.exited
50
- if (code !== 0) throw new Error("copy failed")
51
- }),
52
- ).catch(() => {})
53
- }
54
-
55
- function target(authz: OAuthAuthz) {
56
- return authz.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? authz.url
57
- }
58
-
59
- function bind(api: TuiPluginApi, authz: OAuthAuthz) {
60
- useKeyboard((evt) => {
61
- if (evt.name !== "c" || evt.ctrl || evt.meta) return
62
- evt.preventDefault()
63
- evt.stopPropagation()
64
- void clip(target(authz))
65
- .then(() => api.ui.toast({ variant: "info", message: "Copied to clipboard" }))
66
- })
67
- }
68
-
69
- function WaitView(props: { api: TuiPluginApi; title: string; authz: OAuthAuthz }) {
70
- bind(props.api, props.authz)
71
- return (
72
- <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
73
- <text attributes={TextAttributes.BOLD}>{props.title}</text>
74
- <text>{props.authz.instructions}</text>
75
- <text>{props.authz.url}</text>
76
- <text>Waiting for authorization...</text>
77
- <text>Press c to copy the link</text>
78
- </box>
79
- )
80
- }
81
-
82
- function CodePrompt(props: {
83
- api: TuiPluginApi
84
- title: string
85
- authz: OAuthAuthz
86
- onConfirm: (value: string) => void
87
- onCancel: () => void
88
- }) {
89
- bind(props.api, props.authz)
90
- return (
91
- <props.api.ui.DialogPrompt
92
- title={props.title}
93
- placeholder="Authorization code"
94
- onConfirm={props.onConfirm}
95
- onCancel={props.onCancel}
96
- description={() => (
97
- <box gap={1}>
98
- <text>{props.authz.instructions}</text>
99
- <text>{props.authz.url}</text>
100
- <text>Press c to copy the code</text>
101
- </box>
102
- )}
103
- />
104
- )
105
- }
106
-
107
- function wait(api: TuiPluginApi, title: string, authz: OAuthAuthz, run: () => Promise<void>) {
108
- api.ui.dialog.replace(() => <WaitView api={api} title={title} authz={authz} />)
109
- void run()
110
- }
111
-
112
- async function choose(api: TuiPluginApi, methods: OAuthMethod[]) {
113
- if (methods.length === 1) return 0
114
- return await new Promise<number | null>((resolve) => {
115
- api.ui.dialog.replace(
116
- () => (
117
- <api.ui.DialogSelect
118
- title="Select auth method"
119
- options={methods.map((item, index) => ({ title: item.method.label, value: index }))}
120
- onSelect={(item) => resolve(item.value)}
121
- />
122
- ),
123
- () => resolve(null),
124
- )
125
- })
126
- }
127
-
128
- async function ask(api: TuiPluginApi, title: string, prompt: ProviderPrompt) {
129
- if (prompt.type === "text") {
130
- return await new Promise<string | null>((resolve) => {
131
- api.ui.dialog.replace(
132
- () => (
133
- <box paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={2}>
134
- <api.ui.DialogPrompt
135
- title={title}
136
- placeholder={prompt.placeholder}
137
- onConfirm={(value) => resolve(value)}
138
- onCancel={() => resolve(null)}
139
- description={() => <text>{prompt.message}</text>}
140
- />
141
- </box>
142
- ),
143
- () => resolve(null),
144
- )
145
- })
146
- }
147
- return await new Promise<string | null>((resolve) => {
148
- api.ui.dialog.replace(
149
- () => (
150
- <box paddingLeft={2} paddingRight={2} paddingTop={2} paddingBottom={2}>
151
- <api.ui.DialogSelect
152
- title={prompt.message}
153
- options={(prompt.options ?? []).map((item) => ({
154
- title: item.label,
155
- value: item.value,
156
- description: item.hint,
157
- }))}
158
- onSelect={(item) => resolve(item.value)}
159
- />
160
- </box>
161
- ),
162
- () => resolve(null),
163
- )
164
- })
165
- }
166
-
167
- async function prompts(api: TuiPluginApi, title: string, method: ProviderMethod) {
168
- const values: Record<string, string> = {}
169
- for (const prompt of method.prompts ?? []) {
170
- if (!visible(prompt, values)) continue
171
- const value = await ask(api, title, prompt)
172
- if (value == null) return
173
- values[prompt.key] = value
174
- }
175
- return values
176
- }
177
-
178
- async function save(api: TuiPluginApi, prev?: Awaited<ReturnType<typeof readCurrentAuth>>) {
179
- let activeUpdated = false
180
- try {
181
- const client = api.client as TuiPluginApi["client"] & {
182
- sync?: { bootstrap?: () => Promise<void> }
183
- }
184
- if (typeof api.client.instance.dispose === "function") {
185
- await api.client.instance.dispose()
186
- }
187
- if (typeof client.sync?.bootstrap === "function") {
188
- await client.sync.bootstrap()
189
- }
190
- const auth = await readCurrentAuth()
191
- if (!auth) {
192
- api.ui.toast({ variant: "error", message: "Login completed, but saved account could not be loaded" })
193
- return false
194
- }
195
- activeUpdated = true
196
- if (same(prev, auth)) {
197
- api.ui.toast({ variant: "warning", message: "Login completed, but OpenAI account did not change" })
198
- return false
199
- }
200
- await upsertSavedAccount(auth)
201
- return true
202
- } catch {
203
- api.ui.toast({
204
- variant: "error",
205
- message: activeUpdated
206
- ? "Login completed, but saving the account failed"
207
- : "Login completed, but account sync failed",
208
- })
209
- return false
210
- }
211
- }
212
-
213
- async function code(api: TuiPluginApi, index: number, method: ProviderMethod, authz: OAuthAuthz) {
214
- const prev = await readCurrentAuth()
215
- const ok = await new Promise<boolean>((resolve) => {
216
- api.ui.dialog.replace(
217
- () => (
218
- <CodePrompt
219
- api={api}
220
- title={method.label}
221
- authz={authz}
222
- onConfirm={async (value) => {
223
- const res = await api.client.provider.oauth.callback({ providerID: "openai", method: index, code: value })
224
- resolve(!res.error)
225
- }}
226
- onCancel={() => resolve(false)}
227
- />
228
- ),
229
- () => resolve(false),
230
- )
231
- })
232
- if (!ok) {
233
- api.ui.toast({ variant: "error", message: "Login failed" })
234
- return false
235
- }
236
- return save(api, prev)
237
- }
238
-
239
- async function auto(api: TuiPluginApi, index: number, method: ProviderMethod, authz: OAuthAuthz) {
240
- const prev = await readCurrentAuth()
241
- const ok = await new Promise<boolean>((resolve) => {
242
- wait(api, method.label, authz, async () => {
243
- const res = await api.client.provider.oauth.callback({ providerID: "openai", method: index })
244
- resolve(!res.error)
245
- })
246
- })
247
- if (!ok) {
248
- api.ui.toast({ variant: "error", message: "Login failed" })
249
- return false
250
- }
251
- return save(api, prev)
252
- }
253
-
254
- export async function hasLogin(api: TuiPluginApi) {
255
- try {
256
- const res = await api.client.provider.auth()
257
- const methods = (res.data?.openai ?? []) as ProviderMethod[]
258
- return methods.some((item) => item.type === "oauth")
259
- } catch {
260
- return false
261
- }
262
- }
263
-
264
- export async function loginOpenAI(api: TuiPluginApi) {
265
- try {
266
- const auth = await api.client.provider.auth()
267
- const methods = ((auth.data?.openai ?? []) as ProviderMethod[])
268
- .map((method, index) => ({ method, index }))
269
- .filter((item) => item.method.type === "oauth")
270
- if (!methods.length) {
271
- api.ui.toast({ variant: "error", message: "OpenAI OAuth login unavailable" })
272
- return false
273
- }
274
- const index = await choose(api, methods)
275
- if (index == null) return false
276
- const picked = methods[index]
277
- const method = picked.method
278
- const inputs = await prompts(api, method.label, method)
279
- if (method.prompts?.length && !inputs) return false
280
- const authz = await api.client.provider.oauth.authorize({
281
- providerID: "openai",
282
- method: picked.index,
283
- inputs,
284
- })
285
- if (authz.error || !authz.data) {
286
- api.ui.toast({ variant: "error", message: "Login failed" })
287
- return false
288
- }
289
- if (authz.data.method === "code") return code(api, picked.index, method, authz.data as OAuthAuthz)
290
- if (authz.data.method === "auto") return auto(api, picked.index, method, authz.data as OAuthAuthz)
291
- api.ui.toast({ variant: "error", message: "Unsupported auth method" })
292
- return false
293
- } catch {
294
- api.ui.toast({ variant: "error", message: "Login failed" })
295
- return false
296
- }
297
- }
package/src/parse.ts DELETED
@@ -1,73 +0,0 @@
1
- import { createHash } from "node:crypto"
2
- import type { Claims, OpenAIAuth, StoreEntry } from "./types"
3
-
4
- function text(v: unknown) {
5
- return typeof v === "string" && v.trim() ? v : undefined
6
- }
7
-
8
- function obj(v: unknown) {
9
- return v && typeof v === "object" ? (v as Record<string, unknown>) : undefined
10
- }
11
-
12
- export function parseJwtClaims(token: string): Claims {
13
- const part = token.split(".")[1]
14
- if (!part) return {}
15
- try {
16
- return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Claims
17
- } catch {
18
- return {}
19
- }
20
- }
21
-
22
- export function extractEmail(auth: OpenAIAuth) {
23
- const claims = parseJwtClaims(auth.access)
24
- const direct = text(claims["https://api.openai.com/profile.email"])
25
- if (direct) return direct
26
- return text(obj(claims["https://api.openai.com/profile"] as unknown)?.email)
27
- }
28
-
29
- export function extractPlan(auth: OpenAIAuth) {
30
- return text(parseJwtClaims(auth.access)["https://api.openai.com/auth.chatgpt_plan_type"])
31
- }
32
-
33
- export function extractAccountId(auth: OpenAIAuth) {
34
- if (text(auth.accountId)) return text(auth.accountId)
35
- const claims = parseJwtClaims(auth.access)
36
- const direct = text(claims.chatgpt_account_id)
37
- if (direct) return direct
38
- const namespaced = text(claims["https://api.openai.com/auth.chatgpt_account_id"])
39
- if (namespaced) return namespaced
40
- const orgs = claims.organizations
41
- if (!Array.isArray(orgs) || !orgs.length) return
42
- return text(obj(orgs[0])?.id)
43
- }
44
-
45
- export function fallback(refresh: string) {
46
- return `fallback:${createHash("sha256").update(refresh).digest("hex").slice(0, 16)}`
47
- }
48
-
49
- export function key(auth: OpenAIAuth) {
50
- return fallback(auth.refresh)
51
- }
52
-
53
- export function entryFromAuth(auth: OpenAIAuth, now = Date.now()): StoreEntry {
54
- return {
55
- key: key(auth),
56
- email: extractEmail(auth),
57
- accountId: extractAccountId(auth),
58
- plan: extractPlan(auth),
59
- savedAt: now,
60
- auth: clean(auth),
61
- }
62
- }
63
-
64
- export function clean(auth: OpenAIAuth): OpenAIAuth {
65
- return {
66
- type: "oauth",
67
- refresh: auth.refresh,
68
- access: auth.access,
69
- expires: auth.expires,
70
- ...(text(auth.accountId) ? { accountId: auth.accountId } : {}),
71
- ...(text(auth.enterpriseUrl) ? { enterpriseUrl: auth.enterpriseUrl } : {}),
72
- }
73
- }
package/src/paths.ts DELETED
@@ -1,27 +0,0 @@
1
- import os from "node:os"
2
- import path from "node:path"
3
-
4
- export function dataPath() {
5
- if (process.env.OPENCODE_TEST_HOME) {
6
- return path.join(process.env.OPENCODE_TEST_HOME, ".local", "share", "opencode")
7
- }
8
- if (process.env.XDG_DATA_HOME) return path.join(process.env.XDG_DATA_HOME, "opencode")
9
- if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "opencode")
10
- if (process.platform === "win32") {
11
- const root = process.env.LOCALAPPDATA || process.env.APPDATA
12
- if (root) return path.join(root, "opencode")
13
- }
14
- return path.join(os.homedir(), ".local", "share", "opencode")
15
- }
16
-
17
- export function authPath() {
18
- return path.join(dataPath(), "auth.json")
19
- }
20
-
21
- export function storeDir() {
22
- return path.join(dataPath(), "auth-switch")
23
- }
24
-
25
- export function storePath() {
26
- return path.join(storeDir(), "accounts.json")
27
- }