dsh-plugin-subscription-usage 0.0.0-stage → 0.1.0

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.
Files changed (167) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +38 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +9 -0
  5. package/lib/auth/claude-code-creds.d.ts +23 -0
  6. package/lib/auth/claude-code-creds.js +172 -0
  7. package/lib/auth/device-flow.d.ts +55 -0
  8. package/lib/auth/device-flow.js +177 -0
  9. package/lib/auth/jwt.d.ts +10 -0
  10. package/lib/auth/jwt.js +25 -0
  11. package/lib/auth/oauth-flow.d.ts +91 -0
  12. package/lib/auth/oauth-flow.js +227 -0
  13. package/lib/auth/pkce.d.ts +31 -0
  14. package/lib/auth/pkce.js +35 -0
  15. package/lib/auth/rpc.d.ts +233 -0
  16. package/lib/auth/rpc.js +501 -0
  17. package/lib/auth/store.d.ts +198 -0
  18. package/lib/auth/store.js +473 -0
  19. package/lib/client/Footer.d.ts +3 -0
  20. package/lib/client/Footer.js +73 -0
  21. package/lib/client/Gauge.d.ts +28 -0
  22. package/lib/client/Gauge.js +139 -0
  23. package/lib/client/ImageGallery.d.ts +54 -0
  24. package/lib/client/ImageGallery.js +112 -0
  25. package/lib/client/ImageGenerateToolview.d.ts +48 -0
  26. package/lib/client/ImageGenerateToolview.js +130 -0
  27. package/lib/client/Notice.d.ts +15 -0
  28. package/lib/client/Notice.js +93 -0
  29. package/lib/client/ProviderCard.d.ts +35 -0
  30. package/lib/client/ProviderCard.js +240 -0
  31. package/lib/client/SpeedSelect.d.ts +72 -0
  32. package/lib/client/SpeedSelect.js +214 -0
  33. package/lib/client/Usage.d.ts +7 -0
  34. package/lib/client/Usage.js +83 -0
  35. package/lib/client/VideoGenerateToolview.d.ts +42 -0
  36. package/lib/client/VideoGenerateToolview.js +172 -0
  37. package/lib/client/context.d.ts +16 -0
  38. package/lib/client/context.js +11 -0
  39. package/lib/client/fast-command.d.ts +2 -0
  40. package/lib/client/fast-command.js +4 -0
  41. package/lib/client/hooks.d.ts +2 -0
  42. package/lib/client/hooks.js +10 -0
  43. package/lib/client/index.d.ts +3 -0
  44. package/lib/client/index.js +113 -0
  45. package/lib/client/locales.d.ts +139 -0
  46. package/lib/client/locales.js +267 -0
  47. package/lib/client/meter.d.ts +91 -0
  48. package/lib/client/meter.js +196 -0
  49. package/lib/client/prefs.d.ts +51 -0
  50. package/lib/client/prefs.js +71 -0
  51. package/lib/client/rpc.d.ts +161 -0
  52. package/lib/client/rpc.js +31 -0
  53. package/lib/client/store.d.ts +67 -0
  54. package/lib/client/store.js +129 -0
  55. package/lib/client/styles.d.ts +7 -0
  56. package/lib/client/styles.js +176 -0
  57. package/lib/client.js +3933 -0
  58. package/lib/client.js.map +1 -0
  59. package/lib/compat.d.ts +55 -0
  60. package/lib/compat.js +20 -0
  61. package/lib/http.d.ts +134 -0
  62. package/lib/http.js +431 -0
  63. package/lib/index.d.ts +121 -0
  64. package/lib/index.js +13210 -0
  65. package/lib/json-text.d.ts +2 -0
  66. package/lib/json-text.js +4 -0
  67. package/lib/model-defaults.d.ts +23 -0
  68. package/lib/model-defaults.js +238 -0
  69. package/lib/model-speeds.d.ts +10 -0
  70. package/lib/model-speeds.js +48 -0
  71. package/lib/models-directory.d.ts +17 -0
  72. package/lib/models-directory.js +25 -0
  73. package/lib/provider-settings.d.ts +55 -0
  74. package/lib/provider-settings.js +215 -0
  75. package/lib/providers/account-preferences.d.ts +46 -0
  76. package/lib/providers/account-preferences.js +188 -0
  77. package/lib/providers/accounts.d.ts +113 -0
  78. package/lib/providers/accounts.js +142 -0
  79. package/lib/providers/antigravity-oauth-client.d.ts +7 -0
  80. package/lib/providers/antigravity-oauth-client.js +7 -0
  81. package/lib/providers/antigravity.d.ts +119 -0
  82. package/lib/providers/antigravity.js +552 -0
  83. package/lib/providers/catalog-refresh.d.ts +4 -0
  84. package/lib/providers/catalog-refresh.js +30 -0
  85. package/lib/providers/catalog-store.d.ts +47 -0
  86. package/lib/providers/catalog-store.js +283 -0
  87. package/lib/providers/claude-cli-version.d.ts +15 -0
  88. package/lib/providers/claude-cli-version.js +24 -0
  89. package/lib/providers/claude.d.ts +212 -0
  90. package/lib/providers/claude.js +726 -0
  91. package/lib/providers/codex-client-version.d.ts +8 -0
  92. package/lib/providers/codex-client-version.js +17 -0
  93. package/lib/providers/codex-search.d.ts +29 -0
  94. package/lib/providers/codex-search.js +198 -0
  95. package/lib/providers/codex.d.ts +253 -0
  96. package/lib/providers/codex.js +930 -0
  97. package/lib/providers/common.d.ts +415 -0
  98. package/lib/providers/common.js +679 -0
  99. package/lib/providers/copilot.d.ts +365 -0
  100. package/lib/providers/copilot.js +956 -0
  101. package/lib/providers/grok.d.ts +214 -0
  102. package/lib/providers/grok.js +750 -0
  103. package/lib/providers/image-pool.d.ts +30 -0
  104. package/lib/providers/image-pool.js +89 -0
  105. package/lib/providers/npm-cli-version.d.ts +56 -0
  106. package/lib/providers/npm-cli-version.js +98 -0
  107. package/lib/providers/pool-family.d.ts +56 -0
  108. package/lib/providers/pool-family.js +45 -0
  109. package/lib/providers/pool-health.d.ts +91 -0
  110. package/lib/providers/pool-health.js +164 -0
  111. package/lib/providers/pool-usage.d.ts +106 -0
  112. package/lib/providers/pool-usage.js +302 -0
  113. package/lib/providers/pool.d.ts +115 -0
  114. package/lib/providers/pool.js +461 -0
  115. package/lib/providers/rate-limit.d.ts +192 -0
  116. package/lib/providers/rate-limit.js +338 -0
  117. package/lib/providers/replay.d.ts +4 -0
  118. package/lib/providers/replay.js +37 -0
  119. package/lib/providers/reset-redemption.d.ts +20 -0
  120. package/lib/providers/reset-redemption.js +70 -0
  121. package/lib/tools/image-generate.d.ts +128 -0
  122. package/lib/tools/image-generate.js +438 -0
  123. package/lib/tools/registration.d.ts +15 -0
  124. package/lib/tools/registration.js +24 -0
  125. package/lib/tools/video-generate.d.ts +89 -0
  126. package/lib/tools/video-generate.js +257 -0
  127. package/lib/tools/x-search.d.ts +58 -0
  128. package/lib/tools/x-search.js +197 -0
  129. package/lib/translate/anthropic.d.ts +180 -0
  130. package/lib/translate/anthropic.js +534 -0
  131. package/lib/translate/antigravity-images.d.ts +11 -0
  132. package/lib/translate/antigravity-images.js +62 -0
  133. package/lib/translate/antigravity-schema.d.ts +4 -0
  134. package/lib/translate/antigravity-schema.js +145 -0
  135. package/lib/translate/antigravity-thinking.d.ts +5 -0
  136. package/lib/translate/antigravity-thinking.js +44 -0
  137. package/lib/translate/antigravity.d.ts +120 -0
  138. package/lib/translate/antigravity.js +412 -0
  139. package/lib/translate/chat-completions.d.ts +120 -0
  140. package/lib/translate/chat-completions.js +382 -0
  141. package/lib/translate/resolved.d.ts +98 -0
  142. package/lib/translate/resolved.js +186 -0
  143. package/lib/translate/responses.d.ts +192 -0
  144. package/lib/translate/responses.js +443 -0
  145. package/lib/translate/sse.d.ts +21 -0
  146. package/lib/translate/sse.js +62 -0
  147. package/lib/usage/headers.d.ts +21 -0
  148. package/lib/usage/headers.js +64 -0
  149. package/lib/usage/pace.d.ts +53 -0
  150. package/lib/usage/pace.js +48 -0
  151. package/lib/usage/service.d.ts +64 -0
  152. package/lib/usage/service.js +104 -0
  153. package/lib/usage-providers/common.d.ts +23 -0
  154. package/lib/usage-providers/common.js +28 -0
  155. package/lib/usage-providers/index.d.ts +42 -0
  156. package/lib/usage-providers/index.js +71 -0
  157. package/lib/usage-providers/keys.d.ts +11 -0
  158. package/lib/usage-providers/keys.js +52 -0
  159. package/lib/usage-providers/kimi.d.ts +4 -0
  160. package/lib/usage-providers/kimi.js +52 -0
  161. package/lib/usage-providers/ollama-cloud.d.ts +4 -0
  162. package/lib/usage-providers/ollama-cloud.js +42 -0
  163. package/lib/usage-providers/zai.d.ts +5 -0
  164. package/lib/usage-providers/zai.js +53 -0
  165. package/locale/en.json +6 -0
  166. package/locale/zh.json +6 -0
  167. package/package.json +115 -4
@@ -0,0 +1,198 @@
1
+ /**
2
+ * On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
3
+ *
4
+ * The file is a JSON object keyed by provider id, each entry holding that
5
+ * provider's ACCOUNTS: a map of account key → session plus the default
6
+ * account's key. Writes are atomic (tmp file + rename) with mode 0600
7
+ * because they carry bearer tokens. Session shapes live here (not in the
8
+ * provider modules) because this file owns the durable format.
9
+ *
10
+ * Backward compatibility: entries written by single-account versions hold
11
+ * the session fields directly (no `accounts` wrapper); reads migrate them
12
+ * in memory, and the next write persists the new shape — existing logins
13
+ * survive the upgrade untouched.
14
+ */
15
+ /** Provider routes this plugin can serve. */
16
+ export type ProviderId = 'codex' | 'claude' | 'grok' | 'copilot' | 'antigravity';
17
+ /** Every provider route, in display order. */
18
+ export declare const PROVIDER_IDS: readonly ProviderId[];
19
+ /** Short names for compact surfaces (composer gauge, notices). The Client reads them from `status`. */
20
+ export declare const PROVIDER_SHORT_NAMES: Record<ProviderId, string>;
21
+ /** Stored ChatGPT/Codex subscription session. */
22
+ export interface CodexSession {
23
+ accessToken: string;
24
+ refreshToken: string;
25
+ /** Epoch milliseconds at which the access token expires. */
26
+ expiresAt: number;
27
+ /** `chatgpt_account_id` claim from the id token; sent as the `chatgpt-account-id` header. */
28
+ accountId: string;
29
+ idToken?: string;
30
+ /** User email from the id token, when the token carried it. */
31
+ emailAddress?: string;
32
+ /** `chatgpt_plan_type` claim from the id token (e.g. `plus`, `pro`), when present. */
33
+ planType?: string;
34
+ }
35
+ /** Stored Claude Pro/Max subscription session. */
36
+ export interface ClaudeSession {
37
+ accessToken: string;
38
+ refreshToken: string;
39
+ /** Epoch milliseconds at which the access token expires. */
40
+ expiresAt: number;
41
+ /** Scope string the tokens were issued with; echoed on refresh. */
42
+ scopes: string;
43
+ emailAddress?: string;
44
+ subscriptionType?: string;
45
+ /**
46
+ * True when this account was imported from Claude Code's own credential
47
+ * store (Keychain/file): only bound accounts sync refreshes back to it.
48
+ */
49
+ keychainBound?: boolean;
50
+ }
51
+ /** Stored Grok (X Premium / xAI) subscription session. */
52
+ export interface GrokSession {
53
+ accessToken: string;
54
+ refreshToken: string;
55
+ /** Epoch milliseconds at which the access token expires. */
56
+ expiresAt: number;
57
+ /** Token endpoint from OIDC discovery; retained for refreshes. */
58
+ tokenEndpoint: string;
59
+ scopes?: string;
60
+ /** Display account: email, username, or subject claim from the id token. */
61
+ account?: string;
62
+ }
63
+ /**
64
+ * Stored GitHub Copilot subscription session. Two token generations are at
65
+ * play: the long-lived GitHub OAuth token from the device flow is kept in
66
+ * `refreshToken`, and `accessToken` carries the short-lived (~30 minutes)
67
+ * Copilot API token exchanged from it. A "refresh" is therefore a fresh
68
+ * exchange against `copilot_internal/v2/token`, not an OAuth grant.
69
+ */
70
+ export interface CopilotSession {
71
+ /** Copilot API token; sent as the bearer on api.githubcopilot.com. */
72
+ accessToken: string;
73
+ /** Long-lived GitHub OAuth token from the device flow. */
74
+ refreshToken: string;
75
+ /** Epoch milliseconds at which the Copilot API token expires. */
76
+ expiresAt: number;
77
+ /** GitHub login name, for the status display. */
78
+ account?: string;
79
+ }
80
+ /** Stored Google OAuth session for the Antigravity v1internal API. */
81
+ export interface AntigravitySession {
82
+ accessToken: string;
83
+ refreshToken: string;
84
+ /** Epoch milliseconds at which the Google access token expires. */
85
+ expiresAt: number;
86
+ /** Cloud AI Companion project required by Antigravity request envelopes. */
87
+ projectId: string;
88
+ /** Google account email, for the status display. */
89
+ account?: string;
90
+ /** Subscription tier observed during project discovery. */
91
+ plan?: string;
92
+ /** Granted Google OAuth scopes, when the token endpoint returned them. */
93
+ scopes?: string;
94
+ }
95
+ /** One provider's accounts: account key → session, plus the default account. */
96
+ export interface ProviderAccounts<S> {
97
+ /** Key of the account direct (non-pool) routes serve; the first login wins. */
98
+ default?: string;
99
+ accounts: Record<string, S>;
100
+ /** Old keys remain bound to their migrated account, never a workspace sibling. */
101
+ aliases?: Record<string, string>;
102
+ }
103
+ /** The durable store shape: per provider, its accounts. */
104
+ export interface SessionMap {
105
+ codex?: ProviderAccounts<CodexSession>;
106
+ claude?: ProviderAccounts<ClaudeSession>;
107
+ grok?: ProviderAccounts<GrokSession>;
108
+ copilot?: ProviderAccounts<CopilotSession>;
109
+ antigravity?: ProviderAccounts<AntigravitySession>;
110
+ }
111
+ /** Any stored session, for provider-agnostic plumbing. */
112
+ export type StoredSession = CodexSession | ClaudeSession | GrokSession | CopilotSession | AntigravitySession;
113
+ /** The session type one provider stores. */
114
+ export type SessionOf<K extends ProviderId> = NonNullable<SessionMap[K]>['accounts'][string];
115
+ /** One account entry as returned by {@link listAccounts} (default first). */
116
+ export interface AccountEntry<S> {
117
+ key: string;
118
+ session: S;
119
+ }
120
+ /**
121
+ * The stable identity of one session's account: Codex keys on workspace AND
122
+ * user (email fallback, workspace-only for unidentified legacy sessions),
123
+ * the others on their display identity, falling
124
+ * back to a refresh-token hash for sessions stored before identity fields
125
+ * existed. Logging the same account in again lands on the same key, so a
126
+ * re-login updates in place instead of duplicating. (The hash fallback can
127
+ * miss that dedup once for a legacy session re-logged with a now-known
128
+ * identity — the duplicate is visible on the Settings page and can simply
129
+ * be logged out.)
130
+ * @param provider - the provider route.
131
+ * @param session - the session to key.
132
+ * @returns the account map key.
133
+ */
134
+ export declare function accountKeyOf(provider: ProviderId, session: StoredSession): string;
135
+ /**
136
+ * Resolve a persisted legacy account key to its canonical account identity.
137
+ * For Codex, a reference that matches no key or alias also resolves through
138
+ * a unique login email or workspace ID (see {@link resolveCodexReference}).
139
+ */
140
+ export declare function resolveAccountKey(provider: ProviderId, account: string, path?: string): Promise<string>;
141
+ /**
142
+ * Absolute path of the auth store file.
143
+ * @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
144
+ */
145
+ export declare function authFilePath(): string;
146
+ /**
147
+ * Read the whole store. A missing file is an empty store; malformed JSON or a
148
+ * malformed entry throws, because silently discarding tokens would strand the
149
+ * user without a diagnosis. Single-account entries are migrated in memory;
150
+ * the next write persists the new shape.
151
+ * @param path - store file path; defaults to {@link authFilePath}.
152
+ * @returns the parsed session map.
153
+ */
154
+ export declare function loadStore(path?: string): Promise<SessionMap>;
155
+ /**
156
+ * List one provider's accounts, default first.
157
+ * @param provider - the provider route.
158
+ * @param path - store file path; defaults to {@link authFilePath}.
159
+ * @returns the account entries in stable order (empty when logged out).
160
+ */
161
+ export declare function listAccounts<K extends ProviderId>(provider: K, path?: string): Promise<AccountEntry<SessionOf<K>>[]>;
162
+ /**
163
+ * Read one account's session.
164
+ * @param provider - the provider route.
165
+ * @param account - the account key; defaults to the provider's default account.
166
+ * @param path - store file path; defaults to {@link authFilePath}.
167
+ * @returns the stored session, or `undefined` when absent.
168
+ */
169
+ export declare function getAccountSession<K extends ProviderId>(provider: K, account?: string, path?: string): Promise<SessionOf<K> | undefined>;
170
+ /**
171
+ * Write one account's session, preserving the others. The first account of a
172
+ * provider becomes its default.
173
+ *
174
+ * The session is validated before it lands: a corrupt entry written here
175
+ * would fail every later read of the whole store (one bad entry breaks all
176
+ * providers' status), so the write path must be as strict as the read path.
177
+ * @param provider - the provider route.
178
+ * @param account - the account key (see {@link accountKeyOf}).
179
+ * @param session - the fresh session from a login or refresh.
180
+ * @param path - store file path; defaults to {@link authFilePath}.
181
+ * @throws when the session is missing accessToken/refreshToken/expiresAt.
182
+ */
183
+ export declare function saveAccountSession<K extends ProviderId>(provider: K, account: string, session: SessionOf<K>, path?: string): Promise<void>;
184
+ /**
185
+ * Delete one account's session (logout). Deleting the default moves the badge
186
+ * to the next remaining account.
187
+ * @param provider - the provider route.
188
+ * @param account - the account key.
189
+ * @param path - store file path; defaults to {@link authFilePath}.
190
+ */
191
+ export declare function deleteAccountSession(provider: ProviderId, account: string, path?: string): Promise<void>;
192
+ /**
193
+ * Pin the account direct (non-pool) routes serve.
194
+ * @param provider - the provider route.
195
+ * @param account - the account key; must exist.
196
+ * @param path - store file path; defaults to {@link authFilePath}.
197
+ */
198
+ export declare function setDefaultAccount(provider: ProviderId, account: string, path?: string): Promise<void>;
@@ -0,0 +1,473 @@
1
+ /**
2
+ * On-disk OAuth session store at `~/.dsh/plugins/subscriptions/auth.json`.
3
+ *
4
+ * The file is a JSON object keyed by provider id, each entry holding that
5
+ * provider's ACCOUNTS: a map of account key → session plus the default
6
+ * account's key. Writes are atomic (tmp file + rename) with mode 0600
7
+ * because they carry bearer tokens. Session shapes live here (not in the
8
+ * provider modules) because this file owns the durable format.
9
+ *
10
+ * Backward compatibility: entries written by single-account versions hold
11
+ * the session fields directly (no `accounts` wrapper); reads migrate them
12
+ * in memory, and the next write persists the new shape — existing logins
13
+ * survive the upgrade untouched.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { decodeJwtPayload } from './jwt.js';
17
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
18
+ import { dirname } from 'node:path';
19
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
20
+ import { parseJsonText } from '../json-text.js';
21
+ /** Every provider route, in display order. */
22
+ export const PROVIDER_IDS = ['codex', 'claude', 'grok', 'copilot', 'antigravity'];
23
+ /** Short names for compact surfaces (composer gauge, notices). The Client reads them from `status`. */
24
+ export const PROVIDER_SHORT_NAMES = {
25
+ codex: 'Codex', claude: 'Claude', grok: 'Grok', copilot: 'Copilot', antigravity: 'Antigravity',
26
+ };
27
+ /**
28
+ * The stable identity of one session's account: Codex keys on workspace AND
29
+ * user (email fallback, workspace-only for unidentified legacy sessions),
30
+ * the others on their display identity, falling
31
+ * back to a refresh-token hash for sessions stored before identity fields
32
+ * existed. Logging the same account in again lands on the same key, so a
33
+ * re-login updates in place instead of duplicating. (The hash fallback can
34
+ * miss that dedup once for a legacy session re-logged with a now-known
35
+ * identity — the duplicate is visible on the Settings page and can simply
36
+ * be logged out.)
37
+ * @param provider - the provider route.
38
+ * @param session - the session to key.
39
+ * @returns the account map key.
40
+ */
41
+ export function accountKeyOf(provider, session) {
42
+ switch (provider) {
43
+ case 'codex': {
44
+ const codex = session;
45
+ const payload = typeof codex.idToken === 'string' ? decodeJwtPayload(codex.idToken) : undefined;
46
+ const auth = claimObject(payload?.['https://api.openai.com/auth']);
47
+ const user = nonEmpty(auth?.chatgpt_user_id) ?? nonEmpty(auth?.user_id);
48
+ const profile = claimObject(payload?.['https://api.openai.com/profile']);
49
+ const email = nonEmpty(codex.emailAddress) ?? nonEmpty(payload?.email) ?? nonEmpty(profile?.email);
50
+ if (user === undefined && email === undefined)
51
+ return codex.accountId;
52
+ // JSON tuple encoding avoids separator collisions and distinguishes IDs from emails.
53
+ return JSON.stringify([codex.accountId, user === undefined ? 'email' : 'user', user ?? email.toLowerCase()]);
54
+ }
55
+ case 'claude':
56
+ return session.emailAddress ?? tokenHash(session.refreshToken);
57
+ case 'grok':
58
+ return session.account ?? tokenHash(session.refreshToken);
59
+ case 'antigravity':
60
+ return session.account ?? tokenHash(session.refreshToken);
61
+ case 'copilot':
62
+ return session.account ?? tokenHash(session.refreshToken);
63
+ }
64
+ }
65
+ function nonEmpty(value) {
66
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
67
+ }
68
+ function claimObject(value) {
69
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
70
+ ? value : undefined;
71
+ }
72
+ /** Resolve only recorded aliases, not an ambiguous workspace-wide match. */
73
+ function resolveAccount(entry, key) {
74
+ const seen = new Set();
75
+ while (!Object.hasOwn(entry.accounts, key)
76
+ && entry.aliases !== undefined
77
+ && Object.hasOwn(entry.aliases, key)
78
+ && !seen.has(key)) {
79
+ seen.add(key);
80
+ key = entry.aliases[key];
81
+ }
82
+ return key;
83
+ }
84
+ /**
85
+ * Resolve a Codex reference that is neither a stored key nor a recorded alias:
86
+ * a config `account` may name the login email (as the other providers' keys
87
+ * do) or the bare workspace ID that keys stored before per-user keys. Only an
88
+ * unambiguous match resolves; two users sharing the workspace stay apart, and
89
+ * the reference is returned unchanged so the caller reports it as missing.
90
+ */
91
+ function resolveCodexReference(entry, reference) {
92
+ const wanted = reference.trim().toLowerCase();
93
+ if (wanted.length === 0)
94
+ return reference;
95
+ const matches = Object.entries(entry.accounts).filter(([, session]) => session.accountId === reference.trim() || codexEmail(session) === wanted);
96
+ return matches.length === 1 ? matches[0][0] : reference;
97
+ }
98
+ /**
99
+ * Resolve a persisted legacy account key to its canonical account identity.
100
+ * For Codex, a reference that matches no key or alias also resolves through
101
+ * a unique login email or workspace ID (see {@link resolveCodexReference}).
102
+ */
103
+ export async function resolveAccountKey(provider, account, path = authFilePath()) {
104
+ const entry = (await loadStore(path))[provider];
105
+ if (entry === undefined)
106
+ return account;
107
+ const key = resolveAccount(entry, account);
108
+ if (provider === 'codex' && !Object.hasOwn(entry.accounts, key)) {
109
+ return resolveCodexReference(entry, key);
110
+ }
111
+ return key;
112
+ }
113
+ /** Migrate workspace-only keys once; retain collisions rather than discard credentials. */
114
+ function codexEmail(session) {
115
+ const payload = typeof session.idToken === 'string' ? decodeJwtPayload(session.idToken) : undefined;
116
+ const profile = claimObject(payload?.['https://api.openai.com/profile']);
117
+ return (nonEmpty(session.emailAddress) ?? nonEmpty(payload?.email) ?? nonEmpty(profile?.email))?.toLowerCase();
118
+ }
119
+ function codexUserKey(session) {
120
+ const key = accountKeyOf('codex', session);
121
+ try {
122
+ const tuple = JSON.parse(key);
123
+ return Array.isArray(tuple) && tuple[1] === 'user' ? key : undefined;
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ /** Upgrade one unambiguous email fallback to the stable user key. */
130
+ function reconcileCodexIdentity(entry, session) {
131
+ const userKey = codexUserKey(session);
132
+ const email = codexEmail(session);
133
+ if (userKey === undefined || email === undefined)
134
+ return undefined;
135
+ const emailKey = JSON.stringify([session.accountId, 'email', email]);
136
+ const previous = entry.accounts[emailKey];
137
+ if (previous === undefined || codexEmail(previous) !== email)
138
+ return undefined;
139
+ // Never merge workspace-wide: the exact normalized workspace/email pair must match.
140
+ if (!Object.hasOwn(entry.accounts, userKey))
141
+ entry.accounts[userKey] = previous;
142
+ delete entry.accounts[emailKey];
143
+ entry.aliases = { ...entry.aliases, [emailKey]: userKey };
144
+ for (const [alias, target] of Object.entries(entry.aliases)) {
145
+ if (target === emailKey)
146
+ entry.aliases[alias] = userKey;
147
+ }
148
+ if (entry.default === emailKey)
149
+ entry.default = userKey;
150
+ return userKey;
151
+ }
152
+ function migrateCodex(entry) {
153
+ for (const [oldKey, session] of Object.entries(entry.accounts)) {
154
+ if (oldKey !== session.accountId)
155
+ continue;
156
+ const key = accountKeyOf('codex', session);
157
+ if (key === oldKey || Object.hasOwn(entry.accounts, key))
158
+ continue;
159
+ entry.accounts[key] = session;
160
+ delete entry.accounts[oldKey];
161
+ entry.aliases = { ...entry.aliases, [oldKey]: key };
162
+ if (entry.default === oldKey)
163
+ entry.default = key;
164
+ }
165
+ }
166
+ /** Short stable hash for sessions without an identity field. */
167
+ function tokenHash(refreshToken) {
168
+ return `token-${createHash('sha256').update(refreshToken).digest('hex').slice(0, 16)}`;
169
+ }
170
+ /**
171
+ * Absolute path of the auth store file.
172
+ * @returns `dshHomePath('plugins', 'subscriptions', 'auth.json')`.
173
+ */
174
+ export function authFilePath() {
175
+ return dshHomePath('plugins', 'subscriptions', 'auth.json');
176
+ }
177
+ /** Store location used before the plugin was renamed; migrated on first read. */
178
+ function legacyAuthFilePath() {
179
+ return dshHomePath('plugins', 'router', 'auth.json');
180
+ }
181
+ /** Check that one durable session carries the fields every session needs. */
182
+ function assertSessionShape(provider, account, value) {
183
+ if (typeof value !== 'object' || value === null) {
184
+ throw new Error(`subscriptions auth store: entry "${provider}/${account}" is not an object; fix or delete the store file`);
185
+ }
186
+ const entry = value;
187
+ if (typeof entry.accessToken !== 'string' || entry.accessToken.length === 0
188
+ || typeof entry.refreshToken !== 'string' || entry.refreshToken.length === 0
189
+ || typeof entry.expiresAt !== 'number' || !Number.isFinite(entry.expiresAt)) {
190
+ throw new Error(`subscriptions auth store: entry "${provider}/${account}" is missing accessToken/refreshToken/expiresAt; fix or delete the store file`);
191
+ }
192
+ if (provider === 'antigravity'
193
+ && (typeof entry.projectId !== 'string' || entry.projectId.length === 0)) {
194
+ throw new Error('subscriptions auth store: entry "antigravity" is missing projectId; log out and complete Antigravity login again');
195
+ }
196
+ }
197
+ /**
198
+ * Read the whole store. A missing file is an empty store; malformed JSON or a
199
+ * malformed entry throws, because silently discarding tokens would strand the
200
+ * user without a diagnosis. Single-account entries are migrated in memory;
201
+ * the next write persists the new shape.
202
+ * @param path - store file path; defaults to {@link authFilePath}.
203
+ * @returns the parsed session map.
204
+ */
205
+ export async function loadStore(path = authFilePath()) {
206
+ let text;
207
+ try {
208
+ text = await readFile(path, 'utf8');
209
+ }
210
+ catch (error) {
211
+ if (error.code !== 'ENOENT')
212
+ throw error;
213
+ // Migrate the pre-rename store once, preserving existing logins.
214
+ if (path !== authFilePath())
215
+ return {};
216
+ try {
217
+ text = await readFile(legacyAuthFilePath(), 'utf8');
218
+ }
219
+ catch (legacyError) {
220
+ if (legacyError.code === 'ENOENT')
221
+ return {};
222
+ throw legacyError;
223
+ }
224
+ const migrated = parseStore(text, legacyAuthFilePath());
225
+ await writeStore(migrated, path);
226
+ await rm(legacyAuthFilePath(), { force: true });
227
+ return migrated;
228
+ }
229
+ return parseStore(text, path);
230
+ }
231
+ /**
232
+ * Parse and migrate store JSON read from `path`. An ACCOUNT entry whose shape
233
+ * is invalid (empty or missing tokens — corruption seen in the wild from a
234
+ * broken keychain import) is SKIPPED instead of rejected: one bad entry must
235
+ * not blind every provider's status read, and a session without tokens is
236
+ * unusable by definition, so nothing of value is discarded. The next write
237
+ * persists the store without the skipped entry. Structural failures (invalid
238
+ * JSON, a non-object file) still throw — those say the file itself is broken.
239
+ */
240
+ function parseStore(text, path) {
241
+ let parsed;
242
+ try {
243
+ parsed = parseJsonText(text);
244
+ }
245
+ catch {
246
+ throw new Error(`subscriptions auth store at ${path} is not valid JSON; fix or delete the file`);
247
+ }
248
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
249
+ throw new Error(`subscriptions auth store at ${path} must be a JSON object keyed by provider; fix or delete the file`);
250
+ }
251
+ const raw = parsed;
252
+ const store = {};
253
+ for (const provider of PROVIDER_IDS) {
254
+ const entry = raw[provider];
255
+ if (entry === undefined)
256
+ continue;
257
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
258
+ console.warn(`subscriptions auth store: entry "${provider}" is not an object; skipped`);
259
+ continue;
260
+ }
261
+ const record = entry;
262
+ if (typeof record.accessToken === 'string') {
263
+ // Single-account format: wrap the bare session, preserving every field.
264
+ if (!isValidSessionShape(record)) {
265
+ console.warn(`subscriptions auth store: legacy entry "${provider}" has no usable tokens; skipped`);
266
+ continue;
267
+ }
268
+ const session = record;
269
+ const key = provider === 'codex' ? session.accountId : accountKeyOf(provider, session);
270
+ store[provider] = { default: key, accounts: { [key]: session } };
271
+ continue;
272
+ }
273
+ const accounts = record.accounts;
274
+ if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) {
275
+ console.warn(`subscriptions auth store: entry "${provider}" has no accounts map; skipped`);
276
+ continue;
277
+ }
278
+ if (record.default !== undefined && typeof record.default !== 'string') {
279
+ console.warn(`subscriptions auth store: entry "${provider}" default is not a string; skipped`);
280
+ continue;
281
+ }
282
+ const kept = {};
283
+ for (const [account, session] of Object.entries(accounts)) {
284
+ if (isValidSessionShape(session)) {
285
+ kept[account] = session;
286
+ }
287
+ else {
288
+ console.warn(`subscriptions auth store: entry "${provider}/${account}" has no usable accessToken/refreshToken/expiresAt; skipped`);
289
+ }
290
+ }
291
+ if (Object.keys(kept).length === 0)
292
+ continue;
293
+ const validDefault = record.default === undefined || record.default in kept
294
+ ? record.default
295
+ : Object.keys(kept)[0];
296
+ store[provider] = { ...record, default: validDefault, accounts: kept };
297
+ }
298
+ if (store.codex !== undefined)
299
+ migrateCodex(store.codex);
300
+ return store;
301
+ }
302
+ /** Whether a value carries the fields every stored session needs (non-empty tokens). */
303
+ function isValidSessionShape(value) {
304
+ if (typeof value !== 'object' || value === null)
305
+ return false;
306
+ const entry = value;
307
+ return typeof entry.accessToken === 'string' && entry.accessToken.length > 0
308
+ && typeof entry.refreshToken === 'string' && entry.refreshToken.length > 0
309
+ && typeof entry.expiresAt === 'number' && Number.isFinite(entry.expiresAt);
310
+ }
311
+ /** Persist the whole store atomically with owner-only permissions. */
312
+ async function writeStore(store, path) {
313
+ await mkdir(dirname(path), { recursive: true });
314
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
315
+ try {
316
+ await writeFile(tmp, JSON.stringify(store, null, 2), { mode: 0o600 });
317
+ // An existing destination keeps its old mode through rename on some
318
+ // filesystems; enforce 0600 on the source before the swap.
319
+ await chmod(tmp, 0o600);
320
+ await rename(tmp, path);
321
+ }
322
+ catch (error) {
323
+ await rm(tmp, { force: true });
324
+ throw error;
325
+ }
326
+ }
327
+ /**
328
+ * One write chain per store path. Every mutation is a read-modify-write of a
329
+ * single JSON file, and the plugin has several independent writers — a login,
330
+ * a logout, and one token refresh per provider account, each on its own
331
+ * schedule. Overlapping them unserialized costs whichever account read the
332
+ * store first its entry.
333
+ *
334
+ * A chain is dropped once nothing is queued behind it, so the map holds an
335
+ * entry only while writes are in flight.
336
+ */
337
+ const writeChains = new Map();
338
+ /**
339
+ * Run one read-modify-write of a store path after every write already queued
340
+ * for it. Callers join the chain synchronously, so call order is write order.
341
+ * @param path - the store file being mutated.
342
+ * @param action - the read-modify-write to run.
343
+ * @returns whatever `action` returns.
344
+ */
345
+ async function serialize(path, action) {
346
+ const previous = writeChains.get(path) ?? Promise.resolve();
347
+ // Both handlers: a failed write must not strand everything queued behind it.
348
+ const next = previous.then(action, action);
349
+ const tail = next.then(() => undefined, () => undefined);
350
+ writeChains.set(path, tail);
351
+ try {
352
+ return await next;
353
+ }
354
+ finally {
355
+ if (writeChains.get(path) === tail)
356
+ writeChains.delete(path);
357
+ }
358
+ }
359
+ /**
360
+ * List one provider's accounts, default first.
361
+ * @param provider - the provider route.
362
+ * @param path - store file path; defaults to {@link authFilePath}.
363
+ * @returns the account entries in stable order (empty when logged out).
364
+ */
365
+ export async function listAccounts(provider, path = authFilePath()) {
366
+ const entry = (await loadStore(path))[provider];
367
+ if (entry === undefined)
368
+ return [];
369
+ const accounts = Object.entries(entry.accounts).map(([key, session]) => ({ key, session }));
370
+ accounts.sort((a, b) => Number(b.key === entry.default) - Number(a.key === entry.default));
371
+ return accounts;
372
+ }
373
+ /**
374
+ * Read one account's session.
375
+ * @param provider - the provider route.
376
+ * @param account - the account key; defaults to the provider's default account.
377
+ * @param path - store file path; defaults to {@link authFilePath}.
378
+ * @returns the stored session, or `undefined` when absent.
379
+ */
380
+ export async function getAccountSession(provider, account, path = authFilePath()) {
381
+ const entry = (await loadStore(path))[provider];
382
+ if (entry === undefined)
383
+ return undefined;
384
+ const key = account ?? entry.default;
385
+ if (key === undefined)
386
+ return undefined;
387
+ return entry.accounts[resolveAccount(entry, key)];
388
+ }
389
+ /**
390
+ * Write one account's session, preserving the others. The first account of a
391
+ * provider becomes its default.
392
+ *
393
+ * The session is validated before it lands: a corrupt entry written here
394
+ * would fail every later read of the whole store (one bad entry breaks all
395
+ * providers' status), so the write path must be as strict as the read path.
396
+ * @param provider - the provider route.
397
+ * @param account - the account key (see {@link accountKeyOf}).
398
+ * @param session - the fresh session from a login or refresh.
399
+ * @param path - store file path; defaults to {@link authFilePath}.
400
+ * @throws when the session is missing accessToken/refreshToken/expiresAt.
401
+ */
402
+ export async function saveAccountSession(provider, account, session, path = authFilePath()) {
403
+ assertSessionShape(provider, account, session);
404
+ return serialize(path, async () => {
405
+ const store = await loadStore(path);
406
+ const entry = store[provider];
407
+ if (entry !== undefined) {
408
+ account = resolveAccount(entry, account);
409
+ if (provider === 'codex') {
410
+ account = reconcileCodexIdentity(entry, session) ?? accountKeyOf('codex', session);
411
+ }
412
+ }
413
+ ;
414
+ store[provider] = {
415
+ ...entry,
416
+ default: entry?.default ?? account,
417
+ accounts: { ...entry?.accounts, [account]: session },
418
+ };
419
+ await writeStore(store, path);
420
+ });
421
+ }
422
+ /**
423
+ * Delete one account's session (logout). Deleting the default moves the badge
424
+ * to the next remaining account.
425
+ * @param provider - the provider route.
426
+ * @param account - the account key.
427
+ * @param path - store file path; defaults to {@link authFilePath}.
428
+ */
429
+ export async function deleteAccountSession(provider, account, path = authFilePath()) {
430
+ return serialize(path, async () => {
431
+ const store = await loadStore(path);
432
+ const entry = store[provider];
433
+ if (entry === undefined)
434
+ return;
435
+ account = resolveAccount(entry, account);
436
+ if (!Object.hasOwn(entry.accounts, account))
437
+ return;
438
+ const accounts = { ...entry.accounts };
439
+ delete accounts[account];
440
+ if (Object.keys(accounts).length === 0) {
441
+ delete store[provider];
442
+ }
443
+ else {
444
+ ;
445
+ store[provider] = {
446
+ ...entry,
447
+ ...entry.default === account ? { default: Object.keys(accounts)[0] } : { default: entry.default },
448
+ accounts,
449
+ };
450
+ }
451
+ await writeStore(store, path);
452
+ });
453
+ }
454
+ /**
455
+ * Pin the account direct (non-pool) routes serve.
456
+ * @param provider - the provider route.
457
+ * @param account - the account key; must exist.
458
+ * @param path - store file path; defaults to {@link authFilePath}.
459
+ */
460
+ export async function setDefaultAccount(provider, account, path = authFilePath()) {
461
+ return serialize(path, async () => {
462
+ const store = await loadStore(path);
463
+ const entry = store[provider];
464
+ if (entry !== undefined)
465
+ account = resolveAccount(entry, account);
466
+ if (entry === undefined || !Object.hasOwn(entry.accounts, account)) {
467
+ throw new Error(`no ${provider} account "${account}" is logged in`);
468
+ }
469
+ ;
470
+ store[provider] = { ...entry, default: account };
471
+ await writeStore(store, path);
472
+ });
473
+ }
@@ -0,0 +1,3 @@
1
+ import type { SurfaceInjected } from './context.js';
2
+ export type FooterProps = Partial<SurfaceInjected>;
3
+ export declare function Footer(props: FooterProps): import("react").JSX.Element | null;