oc-codex-multi-auth 6.0.0 → 6.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 (270) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +58 -18
  3. package/assets/readme-hero.svg +10 -10
  4. package/config/README.md +1 -1
  5. package/config/minimal-opencode.json +1 -1
  6. package/config/opencode-legacy.json +1 -1
  7. package/config/opencode-modern.json +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +98 -2520
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/accounts/persistence.d.ts +34 -0
  13. package/dist/lib/accounts/persistence.d.ts.map +1 -0
  14. package/dist/lib/accounts/persistence.js +153 -0
  15. package/dist/lib/accounts/persistence.js.map +1 -0
  16. package/dist/lib/accounts/recovery.d.ts +59 -0
  17. package/dist/lib/accounts/recovery.d.ts.map +1 -0
  18. package/dist/lib/accounts/recovery.js +229 -0
  19. package/dist/lib/accounts/recovery.js.map +1 -0
  20. package/dist/lib/accounts/rotation.d.ts +40 -0
  21. package/dist/lib/accounts/rotation.d.ts.map +1 -0
  22. package/dist/lib/accounts/rotation.js +204 -0
  23. package/dist/lib/accounts/rotation.js.map +1 -0
  24. package/dist/lib/accounts/state.d.ts +92 -0
  25. package/dist/lib/accounts/state.d.ts.map +1 -0
  26. package/dist/lib/accounts/state.js +366 -0
  27. package/dist/lib/accounts/state.js.map +1 -0
  28. package/dist/lib/accounts.d.ts +48 -91
  29. package/dist/lib/accounts.d.ts.map +1 -1
  30. package/dist/lib/accounts.js +121 -699
  31. package/dist/lib/accounts.js.map +1 -1
  32. package/dist/lib/auth/auth.d.ts +1 -1
  33. package/dist/lib/auth/auth.d.ts.map +1 -1
  34. package/dist/lib/auth/auth.js +5 -2
  35. package/dist/lib/auth/auth.js.map +1 -1
  36. package/dist/lib/auth/login-runner.d.ts +32 -0
  37. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  38. package/dist/lib/auth/login-runner.js +79 -64
  39. package/dist/lib/auth/login-runner.js.map +1 -1
  40. package/dist/lib/auth/server.d.ts.map +1 -1
  41. package/dist/lib/auth/server.js +3 -8
  42. package/dist/lib/auth/server.js.map +1 -1
  43. package/dist/lib/circuit-breaker.d.ts +53 -2
  44. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  45. package/dist/lib/circuit-breaker.js +65 -6
  46. package/dist/lib/circuit-breaker.js.map +1 -1
  47. package/dist/lib/config.d.ts.map +1 -1
  48. package/dist/lib/config.js +77 -32
  49. package/dist/lib/config.js.map +1 -1
  50. package/dist/lib/error-sentinels.d.ts +20 -0
  51. package/dist/lib/error-sentinels.d.ts.map +1 -0
  52. package/dist/lib/error-sentinels.js +28 -0
  53. package/dist/lib/error-sentinels.js.map +1 -0
  54. package/dist/lib/errors.d.ts +100 -1
  55. package/dist/lib/errors.d.ts.map +1 -1
  56. package/dist/lib/errors.js +116 -1
  57. package/dist/lib/errors.js.map +1 -1
  58. package/dist/lib/logger.d.ts +14 -1
  59. package/dist/lib/logger.d.ts.map +1 -1
  60. package/dist/lib/logger.js +65 -5
  61. package/dist/lib/logger.js.map +1 -1
  62. package/dist/lib/oauth-constants.d.ts +18 -0
  63. package/dist/lib/oauth-constants.d.ts.map +1 -0
  64. package/dist/lib/oauth-constants.js +18 -0
  65. package/dist/lib/oauth-constants.js.map +1 -0
  66. package/dist/lib/oauth-success.d.ts +2 -0
  67. package/dist/lib/oauth-success.d.ts.map +1 -0
  68. package/dist/lib/oauth-success.html +711 -711
  69. package/dist/lib/oauth-success.js +714 -0
  70. package/dist/lib/oauth-success.js.map +1 -0
  71. package/dist/lib/prompts/codex.d.ts.map +1 -1
  72. package/dist/lib/prompts/codex.js +12 -3
  73. package/dist/lib/prompts/codex.js.map +1 -1
  74. package/dist/lib/prompts/opencode-codex.d.ts.map +1 -1
  75. package/dist/lib/prompts/opencode-codex.js +3 -2
  76. package/dist/lib/prompts/opencode-codex.js.map +1 -1
  77. package/dist/lib/recovery/hook.d.ts +43 -0
  78. package/dist/lib/recovery/hook.d.ts.map +1 -0
  79. package/dist/lib/recovery/hook.js +325 -0
  80. package/dist/lib/recovery/hook.js.map +1 -0
  81. package/dist/lib/recovery/index.d.ts +1 -0
  82. package/dist/lib/recovery/index.d.ts.map +1 -1
  83. package/dist/lib/recovery/index.js +1 -0
  84. package/dist/lib/recovery/index.js.map +1 -1
  85. package/dist/lib/recovery/storage.d.ts.map +1 -1
  86. package/dist/lib/recovery/storage.js +5 -1
  87. package/dist/lib/recovery/storage.js.map +1 -1
  88. package/dist/lib/recovery.d.ts +15 -30
  89. package/dist/lib/recovery.d.ts.map +1 -1
  90. package/dist/lib/recovery.js +14 -312
  91. package/dist/lib/recovery.js.map +1 -1
  92. package/dist/lib/request/fetch-helpers.js +1 -1
  93. package/dist/lib/request/fetch-helpers.js.map +1 -1
  94. package/dist/lib/request/response-handler.d.ts.map +1 -1
  95. package/dist/lib/request/response-handler.js +8 -2
  96. package/dist/lib/request/response-handler.js.map +1 -1
  97. package/dist/lib/runtime.d.ts +101 -0
  98. package/dist/lib/runtime.d.ts.map +1 -0
  99. package/dist/lib/runtime.js +77 -0
  100. package/dist/lib/runtime.js.map +1 -0
  101. package/dist/lib/schemas.d.ts +164 -1
  102. package/dist/lib/schemas.d.ts.map +1 -1
  103. package/dist/lib/schemas.js +251 -0
  104. package/dist/lib/schemas.js.map +1 -1
  105. package/dist/lib/storage/atomic-write.d.ts +31 -0
  106. package/dist/lib/storage/atomic-write.d.ts.map +1 -0
  107. package/dist/lib/storage/atomic-write.js +75 -0
  108. package/dist/lib/storage/atomic-write.js.map +1 -0
  109. package/dist/lib/storage/backup.d.ts +13 -0
  110. package/dist/lib/storage/backup.d.ts.map +1 -0
  111. package/dist/lib/storage/backup.js +59 -0
  112. package/dist/lib/storage/backup.js.map +1 -0
  113. package/dist/lib/storage/errors.d.ts +17 -0
  114. package/dist/lib/storage/errors.d.ts.map +1 -0
  115. package/dist/lib/storage/errors.js +38 -0
  116. package/dist/lib/storage/errors.js.map +1 -0
  117. package/dist/lib/storage/export-import.d.ts +84 -0
  118. package/dist/lib/storage/export-import.d.ts.map +1 -0
  119. package/dist/lib/storage/export-import.js +222 -0
  120. package/dist/lib/storage/export-import.js.map +1 -0
  121. package/dist/lib/storage/flagged.d.ts +29 -0
  122. package/dist/lib/storage/flagged.d.ts.map +1 -0
  123. package/dist/lib/storage/flagged.js +207 -0
  124. package/dist/lib/storage/flagged.js.map +1 -0
  125. package/dist/lib/storage/identity.d.ts +72 -0
  126. package/dist/lib/storage/identity.d.ts.map +1 -0
  127. package/dist/lib/storage/identity.js +230 -0
  128. package/dist/lib/storage/identity.js.map +1 -0
  129. package/dist/lib/storage/keychain.d.ts +146 -0
  130. package/dist/lib/storage/keychain.d.ts.map +1 -0
  131. package/dist/lib/storage/keychain.js +278 -0
  132. package/dist/lib/storage/keychain.js.map +1 -0
  133. package/dist/lib/storage/load-save.d.ts +61 -0
  134. package/dist/lib/storage/load-save.d.ts.map +1 -0
  135. package/dist/lib/storage/load-save.js +606 -0
  136. package/dist/lib/storage/load-save.js.map +1 -0
  137. package/dist/lib/storage/migrations.d.ts +35 -0
  138. package/dist/lib/storage/migrations.d.ts.map +1 -1
  139. package/dist/lib/storage/migrations.js +37 -0
  140. package/dist/lib/storage/migrations.js.map +1 -1
  141. package/dist/lib/storage/normalize.d.ts +22 -0
  142. package/dist/lib/storage/normalize.d.ts.map +1 -0
  143. package/dist/lib/storage/normalize.js +114 -0
  144. package/dist/lib/storage/normalize.js.map +1 -0
  145. package/dist/lib/storage/paths.d.ts.map +1 -1
  146. package/dist/lib/storage/paths.js +2 -1
  147. package/dist/lib/storage/paths.js.map +1 -1
  148. package/dist/lib/storage/state.d.ts +46 -0
  149. package/dist/lib/storage/state.d.ts.map +1 -0
  150. package/dist/lib/storage/state.js +100 -0
  151. package/dist/lib/storage/state.js.map +1 -0
  152. package/dist/lib/storage/worktree-lock.d.ts +79 -0
  153. package/dist/lib/storage/worktree-lock.d.ts.map +1 -0
  154. package/dist/lib/storage/worktree-lock.js +265 -0
  155. package/dist/lib/storage/worktree-lock.js.map +1 -0
  156. package/dist/lib/storage.d.ts +21 -155
  157. package/dist/lib/storage.d.ts.map +1 -1
  158. package/dist/lib/storage.js +24 -1167
  159. package/dist/lib/storage.js.map +1 -1
  160. package/dist/lib/tools/_shared.d.ts +16 -0
  161. package/dist/lib/tools/_shared.d.ts.map +1 -0
  162. package/dist/lib/tools/_shared.js +21 -0
  163. package/dist/lib/tools/_shared.js.map +1 -0
  164. package/dist/lib/tools/codex-dashboard.d.ts +8 -0
  165. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -0
  166. package/dist/lib/tools/codex-dashboard.js +176 -0
  167. package/dist/lib/tools/codex-dashboard.js.map +1 -0
  168. package/dist/lib/tools/codex-diag.d.ts +19 -0
  169. package/dist/lib/tools/codex-diag.d.ts.map +1 -0
  170. package/dist/lib/tools/codex-diag.js +139 -0
  171. package/dist/lib/tools/codex-diag.js.map +1 -0
  172. package/dist/lib/tools/codex-diff.d.ts +44 -0
  173. package/dist/lib/tools/codex-diff.d.ts.map +1 -0
  174. package/dist/lib/tools/codex-diff.js +284 -0
  175. package/dist/lib/tools/codex-diff.js.map +1 -0
  176. package/dist/lib/tools/codex-doctor.d.ts +8 -0
  177. package/dist/lib/tools/codex-doctor.d.ts.map +1 -0
  178. package/dist/lib/tools/codex-doctor.js +284 -0
  179. package/dist/lib/tools/codex-doctor.js.map +1 -0
  180. package/dist/lib/tools/codex-export.d.ts +8 -0
  181. package/dist/lib/tools/codex-export.d.ts.map +1 -0
  182. package/dist/lib/tools/codex-export.js +63 -0
  183. package/dist/lib/tools/codex-export.js.map +1 -0
  184. package/dist/lib/tools/codex-health.d.ts +8 -0
  185. package/dist/lib/tools/codex-health.d.ts.map +1 -0
  186. package/dist/lib/tools/codex-health.js +124 -0
  187. package/dist/lib/tools/codex-health.js.map +1 -0
  188. package/dist/lib/tools/codex-help.d.ts +8 -0
  189. package/dist/lib/tools/codex-help.d.ts.map +1 -0
  190. package/dist/lib/tools/codex-help.js +127 -0
  191. package/dist/lib/tools/codex-help.js.map +1 -0
  192. package/dist/lib/tools/codex-import.d.ts +8 -0
  193. package/dist/lib/tools/codex-import.d.ts.map +1 -0
  194. package/dist/lib/tools/codex-import.js +97 -0
  195. package/dist/lib/tools/codex-import.js.map +1 -0
  196. package/dist/lib/tools/codex-keychain.d.ts +36 -0
  197. package/dist/lib/tools/codex-keychain.d.ts.map +1 -0
  198. package/dist/lib/tools/codex-keychain.js +289 -0
  199. package/dist/lib/tools/codex-keychain.js.map +1 -0
  200. package/dist/lib/tools/codex-label.d.ts +8 -0
  201. package/dist/lib/tools/codex-label.d.ts.map +1 -0
  202. package/dist/lib/tools/codex-label.js +143 -0
  203. package/dist/lib/tools/codex-label.js.map +1 -0
  204. package/dist/lib/tools/codex-limits.d.ts +8 -0
  205. package/dist/lib/tools/codex-limits.d.ts.map +1 -0
  206. package/dist/lib/tools/codex-limits.js +497 -0
  207. package/dist/lib/tools/codex-limits.js.map +1 -0
  208. package/dist/lib/tools/codex-list.d.ts +8 -0
  209. package/dist/lib/tools/codex-list.d.ts.map +1 -0
  210. package/dist/lib/tools/codex-list.js +260 -0
  211. package/dist/lib/tools/codex-list.js.map +1 -0
  212. package/dist/lib/tools/codex-metrics.d.ts +8 -0
  213. package/dist/lib/tools/codex-metrics.d.ts.map +1 -0
  214. package/dist/lib/tools/codex-metrics.js +180 -0
  215. package/dist/lib/tools/codex-metrics.js.map +1 -0
  216. package/dist/lib/tools/codex-next.d.ts +8 -0
  217. package/dist/lib/tools/codex-next.d.ts.map +1 -0
  218. package/dist/lib/tools/codex-next.js +54 -0
  219. package/dist/lib/tools/codex-next.js.map +1 -0
  220. package/dist/lib/tools/codex-note.d.ts +8 -0
  221. package/dist/lib/tools/codex-note.d.ts.map +1 -0
  222. package/dist/lib/tools/codex-note.js +79 -0
  223. package/dist/lib/tools/codex-note.js.map +1 -0
  224. package/dist/lib/tools/codex-refresh.d.ts +8 -0
  225. package/dist/lib/tools/codex-refresh.d.ts.map +1 -0
  226. package/dist/lib/tools/codex-refresh.js +78 -0
  227. package/dist/lib/tools/codex-refresh.js.map +1 -0
  228. package/dist/lib/tools/codex-remove.d.ts +8 -0
  229. package/dist/lib/tools/codex-remove.d.ts.map +1 -0
  230. package/dist/lib/tools/codex-remove.js +184 -0
  231. package/dist/lib/tools/codex-remove.js.map +1 -0
  232. package/dist/lib/tools/codex-setup.d.ts +8 -0
  233. package/dist/lib/tools/codex-setup.d.ts.map +1 -0
  234. package/dist/lib/tools/codex-setup.js +26 -0
  235. package/dist/lib/tools/codex-setup.js.map +1 -0
  236. package/dist/lib/tools/codex-status.d.ts +8 -0
  237. package/dist/lib/tools/codex-status.d.ts.map +1 -0
  238. package/dist/lib/tools/codex-status.js +263 -0
  239. package/dist/lib/tools/codex-status.js.map +1 -0
  240. package/dist/lib/tools/codex-switch.d.ts +8 -0
  241. package/dist/lib/tools/codex-switch.d.ts.map +1 -0
  242. package/dist/lib/tools/codex-switch.js +122 -0
  243. package/dist/lib/tools/codex-switch.js.map +1 -0
  244. package/dist/lib/tools/codex-tag.d.ts +8 -0
  245. package/dist/lib/tools/codex-tag.d.ts.map +1 -0
  246. package/dist/lib/tools/codex-tag.js +101 -0
  247. package/dist/lib/tools/codex-tag.js.map +1 -0
  248. package/dist/lib/tools/index.d.ts +143 -0
  249. package/dist/lib/tools/index.d.ts.map +1 -0
  250. package/dist/lib/tools/index.js +67 -0
  251. package/dist/lib/tools/index.js.map +1 -0
  252. package/dist/lib/ui/format.d.ts.map +1 -1
  253. package/dist/lib/ui/format.js +5 -0
  254. package/dist/lib/ui/format.js.map +1 -1
  255. package/dist/lib/ui/runtime.d.ts +10 -0
  256. package/dist/lib/ui/runtime.d.ts.map +1 -1
  257. package/dist/lib/ui/runtime.js +16 -11
  258. package/dist/lib/ui/runtime.js.map +1 -1
  259. package/dist/lib/ui/select.d.ts.map +1 -1
  260. package/dist/lib/ui/select.js +12 -3
  261. package/dist/lib/ui/select.js.map +1 -1
  262. package/dist/lib/ui/theme.d.ts +19 -0
  263. package/dist/lib/ui/theme.d.ts.map +1 -1
  264. package/dist/lib/ui/theme.js +27 -0
  265. package/dist/lib/ui/theme.js.map +1 -1
  266. package/package.json +24 -16
  267. package/scripts/copy-oauth-success.js +16 -6
  268. package/scripts/install-oc-codex-multi-auth-core.js +462 -381
  269. package/scripts/install-oc-codex-multi-auth.js +15 -15
  270. package/scripts/test-all-models.sh +1 -1
@@ -1,1169 +1,26 @@
1
- import { promises as fs, existsSync } from "node:fs";
2
- import { randomBytes } from "node:crypto";
3
- import { basename, dirname, join } from "node:path";
4
- import { ACCOUNT_LIMITS, ACCOUNTS_FILE_NAME, FLAGGED_ACCOUNTS_FILE_NAME, LEGACY_ACCOUNTS_FILE_NAME, LEGACY_BLOCKED_ACCOUNTS_FILE_NAME, LEGACY_FLAGGED_ACCOUNTS_FILE_NAME, } from "./constants.js";
5
- import { createLogger } from "./logger.js";
6
- import { MODEL_FAMILIES } from "./prompts/codex.js";
7
- import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js";
8
- import { getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, findProjectRoot, resolvePath } from "./storage/paths.js";
9
- import { migrateV1ToV3, } from "./storage/migrations.js";
10
- const log = createLogger("storage");
11
- const normalizeWorkspaceIdentityPart = (value) => typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
12
- export function getWorkspaceIdentityKey(account) {
13
- const organizationId = normalizeWorkspaceIdentityPart(account.organizationId);
14
- const accountId = normalizeWorkspaceIdentityPart(account.accountId);
15
- const refreshToken = normalizeWorkspaceIdentityPart(account.refreshToken) ?? "";
16
- if (organizationId) {
17
- return accountId
18
- ? `organizationId:${organizationId}|accountId:${accountId}`
19
- : `organizationId:${organizationId}`;
20
- }
21
- if (accountId) {
22
- return `accountId:${accountId}`;
23
- }
24
- return `refreshToken:${refreshToken}`;
25
- }
26
1
  /**
27
- * Custom error class for storage operations with platform-aware hints.
28
- */
29
- export class StorageError extends Error {
30
- code;
31
- path;
32
- hint;
33
- constructor(message, code, path, hint, cause) {
34
- super(message, { cause });
35
- this.name = "StorageError";
36
- this.code = code;
37
- this.path = path;
38
- this.hint = hint;
39
- }
40
- }
41
- /**
42
- * Generate platform-aware troubleshooting hint based on error code.
43
- */
44
- export function formatStorageErrorHint(error, path) {
45
- const err = error;
46
- const code = err?.code || "UNKNOWN";
47
- const isWindows = process.platform === "win32";
48
- switch (code) {
49
- case "EACCES":
50
- case "EPERM":
51
- return isWindows
52
- ? `Permission denied writing to ${path}. Check antivirus exclusions for this folder. Ensure you have write permissions.`
53
- : `Permission denied writing to ${path}. Check folder permissions. Try: chmod 755 ~/.opencode`;
54
- case "EBUSY":
55
- return `File is locked at ${path}. The file may be open in another program. Close any editors or processes accessing it.`;
56
- case "ENOSPC":
57
- return `Disk is full. Free up space and try again. Path: ${path}`;
58
- case "EEMPTY":
59
- return `File written but is empty. This may indicate a disk or filesystem issue. Path: ${path}`;
60
- default:
61
- return isWindows
62
- ? `Failed to write to ${path}. Check folder permissions and ensure path contains no special characters.`
63
- : `Failed to write to ${path}. Check folder permissions and disk space.`;
64
- }
65
- }
66
- let storageMutex = Promise.resolve();
67
- /**
68
- * Serializes storage I/O to keep account file reads/writes lock-step and avoid
69
- * cross-request races during migration/seeding flows.
70
- */
71
- function withStorageLock(fn) {
72
- const previousMutex = storageMutex;
73
- let releaseLock;
74
- storageMutex = new Promise((resolve) => {
75
- releaseLock = resolve;
76
- });
77
- return previousMutex.then(fn).finally(() => releaseLock());
78
- }
79
- const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
80
- const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
81
- const PRE_IMPORT_BACKUP_WRITE_TIMEOUT_MS = 3_000;
82
- function isWindowsLockError(error) {
83
- const code = error?.code;
84
- return code === "EPERM" || code === "EBUSY";
85
- }
86
- async function renameWithWindowsRetry(sourcePath, destinationPath) {
87
- let lastError = null;
88
- for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
89
- try {
90
- await fs.rename(sourcePath, destinationPath);
91
- return;
92
- }
93
- catch (error) {
94
- if (isWindowsLockError(error)) {
95
- lastError = error;
96
- await new Promise((resolve) => setTimeout(resolve, WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt));
97
- continue;
98
- }
99
- throw error;
100
- }
101
- }
102
- if (lastError) {
103
- throw lastError;
104
- }
105
- }
106
- async function writeFileWithTimeout(filePath, content, timeoutMs) {
107
- const controller = new AbortController();
108
- const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
109
- try {
110
- await fs.writeFile(filePath, content, {
111
- encoding: "utf-8",
112
- mode: 0o600,
113
- signal: controller.signal,
114
- });
115
- }
116
- catch (error) {
117
- if (error instanceof Error && error.name === "AbortError") {
118
- const timeoutError = Object.assign(new Error(`Timed out writing file after ${timeoutMs}ms`), { code: "ETIMEDOUT" });
119
- throw timeoutError;
120
- }
121
- throw error;
122
- }
123
- finally {
124
- clearTimeout(timeoutHandle);
125
- }
126
- }
127
- async function writePreImportBackupFile(backupPath, snapshot) {
128
- const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
129
- const tempPath = `${backupPath}.${uniqueSuffix}.tmp`;
130
- try {
131
- await fs.mkdir(dirname(backupPath), { recursive: true });
132
- const backupContent = JSON.stringify(snapshot, null, 2);
133
- await writeFileWithTimeout(tempPath, backupContent, PRE_IMPORT_BACKUP_WRITE_TIMEOUT_MS);
134
- await renameWithWindowsRetry(tempPath, backupPath);
135
- }
136
- catch (error) {
137
- try {
138
- await fs.unlink(tempPath);
139
- }
140
- catch {
141
- // Best effort temp-file cleanup.
142
- }
143
- throw error;
144
- }
145
- }
146
- async function ensureGitignore(storagePath) {
147
- if (!currentStoragePath)
148
- return;
149
- const configDir = dirname(storagePath);
150
- const inferredProjectRoot = dirname(configDir);
151
- const candidateRoots = [currentProjectRoot, inferredProjectRoot].filter((root) => typeof root === "string" && root.length > 0);
152
- const projectRoot = candidateRoots.find((root) => existsSync(join(root, ".git")));
153
- if (!projectRoot)
154
- return;
155
- const gitignorePath = join(projectRoot, ".gitignore");
156
- try {
157
- let content = "";
158
- if (existsSync(gitignorePath)) {
159
- content = await fs.readFile(gitignorePath, "utf-8");
160
- const lines = content.split("\n").map((l) => l.trim());
161
- if (lines.includes(".opencode") || lines.includes(".opencode/") || lines.includes("/.opencode") || lines.includes("/.opencode/")) {
162
- return;
163
- }
164
- }
165
- const newContent = content.endsWith("\n") || content === "" ? content : content + "\n";
166
- await fs.writeFile(gitignorePath, newContent + ".opencode/\n", "utf-8");
167
- log.debug("Added .opencode to .gitignore", { path: gitignorePath });
168
- }
169
- catch (error) {
170
- log.warn("Failed to update .gitignore", { error: String(error) });
171
- }
172
- }
173
- let currentStoragePath = null;
174
- let currentLegacyProjectStoragePath = null;
175
- let currentProjectRoot = null;
176
- export function setStoragePath(projectPath) {
177
- if (!projectPath) {
178
- currentStoragePath = null;
179
- currentLegacyProjectStoragePath = null;
180
- currentProjectRoot = null;
181
- return;
182
- }
183
- const projectRoot = findProjectRoot(projectPath);
184
- if (projectRoot) {
185
- currentProjectRoot = projectRoot;
186
- currentStoragePath = join(getProjectGlobalConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
187
- currentLegacyProjectStoragePath = join(getProjectConfigDir(projectRoot), LEGACY_ACCOUNTS_FILE_NAME);
188
- }
189
- else {
190
- currentStoragePath = null;
191
- currentLegacyProjectStoragePath = null;
192
- currentProjectRoot = null;
193
- }
194
- }
195
- export function setStoragePathDirect(path) {
196
- currentStoragePath = path;
197
- currentLegacyProjectStoragePath = null;
198
- currentProjectRoot = null;
199
- }
200
- /**
201
- * Returns the file path for the account storage JSON file.
202
- * @returns Absolute path to the accounts.json file
203
- */
204
- export function getStoragePath() {
205
- if (currentStoragePath) {
206
- return currentStoragePath;
207
- }
208
- return join(getConfigDir(), ACCOUNTS_FILE_NAME);
209
- }
210
- export function getFlaggedAccountsPath() {
211
- return join(dirname(getStoragePath()), FLAGGED_ACCOUNTS_FILE_NAME);
212
- }
213
- function getLegacyFlaggedAccountsPath() {
214
- return join(dirname(getStoragePath()), LEGACY_FLAGGED_ACCOUNTS_FILE_NAME);
215
- }
216
- function getLegacyBlockedAccountsPath() {
217
- return join(dirname(getStoragePath()), LEGACY_BLOCKED_ACCOUNTS_FILE_NAME);
218
- }
219
- async function migrateStorageFileIfNeeded(legacyPath, nextPath, persist, label) {
220
- if (!legacyPath || legacyPath === nextPath || !existsSync(legacyPath)) {
221
- return null;
222
- }
223
- try {
224
- const legacyContent = await fs.readFile(legacyPath, "utf-8");
225
- const legacyData = JSON.parse(legacyContent);
226
- const normalized = normalizeAccountStorage(legacyData);
227
- if (!normalized)
228
- return null;
229
- await persist(normalized);
230
- try {
231
- await fs.unlink(legacyPath);
232
- log.info(`Removed legacy ${label} after migration`, { path: legacyPath });
233
- }
234
- catch (unlinkError) {
235
- const code = unlinkError.code;
236
- if (code !== "ENOENT") {
237
- log.warn(`Failed to remove legacy ${label} after migration`, {
238
- path: legacyPath,
239
- error: String(unlinkError),
240
- });
241
- }
242
- }
243
- log.info(`Migrated legacy ${label}`, {
244
- from: legacyPath,
245
- to: nextPath,
246
- accounts: normalized.accounts.length,
247
- });
248
- return normalized;
249
- }
250
- catch (error) {
251
- log.warn(`Failed to migrate legacy ${label}`, {
252
- from: legacyPath,
253
- to: nextPath,
254
- error: String(error),
255
- });
256
- return null;
257
- }
258
- }
259
- async function migrateLegacyProjectStorageIfNeeded(persist = saveAccounts) {
260
- return migrateStorageFileIfNeeded(currentLegacyProjectStoragePath, getStoragePath(), persist, "project account storage");
261
- }
262
- function selectNewestAccount(current, candidate) {
263
- if (!current)
264
- return candidate;
265
- const currentLastUsed = current.lastUsed || 0;
266
- const candidateLastUsed = candidate.lastUsed || 0;
267
- if (candidateLastUsed > currentLastUsed)
268
- return candidate;
269
- if (candidateLastUsed < currentLastUsed)
270
- return current;
271
- const currentAddedAt = current.addedAt || 0;
272
- const candidateAddedAt = candidate.addedAt || 0;
273
- return candidateAddedAt >= currentAddedAt ? candidate : current;
274
- }
275
- function deduplicateAccountsByKey(accounts) {
276
- const working = [...accounts];
277
- const keyToIndex = new Map();
278
- const indicesToRemove = new Set();
279
- for (let i = 0; i < working.length; i += 1) {
280
- const account = working[i];
281
- if (!account)
282
- continue;
283
- const key = toAccountIdentityKey(account);
284
- if (!key)
285
- continue;
286
- const existingIndex = keyToIndex.get(key);
287
- if (existingIndex === undefined) {
288
- keyToIndex.set(key, i);
289
- continue;
290
- }
291
- const newestIndex = pickNewestAccountIndex(working, existingIndex, i);
292
- const obsoleteIndex = newestIndex === existingIndex ? i : existingIndex;
293
- const target = working[newestIndex];
294
- const source = working[obsoleteIndex];
295
- if (target && source) {
296
- working[newestIndex] = mergeAccountRecords(target, source);
297
- }
298
- indicesToRemove.add(obsoleteIndex);
299
- keyToIndex.set(key, newestIndex);
300
- }
301
- const result = [];
302
- for (let i = 0; i < working.length; i += 1) {
303
- if (indicesToRemove.has(i))
304
- continue;
305
- const account = working[i];
306
- if (account)
307
- result.push(account);
308
- }
309
- return result;
310
- }
311
- function pickNewestAccountIndex(accounts, existingIndex, candidateIndex) {
312
- const existing = accounts[existingIndex];
313
- const candidate = accounts[candidateIndex];
314
- if (!existing)
315
- return candidateIndex;
316
- if (!candidate)
317
- return existingIndex;
318
- const newest = selectNewestAccount(existing, candidate);
319
- return newest === candidate ? candidateIndex : existingIndex;
320
- }
321
- function mergeAccountRecords(target, source) {
322
- const newest = selectNewestAccount(target, source);
323
- const older = newest === target ? source : target;
324
- return {
325
- ...older,
326
- ...newest,
327
- organizationId: target.organizationId ?? source.organizationId,
328
- accountId: target.accountId ?? source.accountId,
329
- accountIdSource: target.accountIdSource ?? source.accountIdSource,
330
- accountLabel: target.accountLabel ?? source.accountLabel,
331
- email: target.email ?? source.email,
332
- };
333
- }
334
- /**
335
- * Removes duplicate accounts, keeping the most recently used entry for each unique key.
336
- * Deduplication identity hierarchy: organizationId -> accountId -> refreshToken.
337
- * @param accounts - Array of accounts to deduplicate
338
- * @returns New array with duplicates removed
339
- */
340
- export function deduplicateAccounts(accounts) {
341
- return deduplicateAccountsByKey(accounts);
342
- }
343
- /**
344
- * Applies storage deduplication semantics used by normalize/import paths.
345
- * 1) Dedupe only exact identity duplicates (organizationId -> accountId -> refreshToken),
346
- * preserving distinct workspace variants that share a refresh token.
347
- * 2) Then apply legacy email dedupe only for entries that still do not have organizationId/accountId.
348
- */
349
- function deduplicateAccountsForStorage(accounts) {
350
- return deduplicateAccountsByEmail(deduplicateAccountsByKey(accounts));
351
- }
352
- /**
353
- * Removes duplicate legacy accounts by email, keeping the most recently used entry.
354
- * Accounts with organizationId/accountId are never merged by email to avoid collapsing workspace variants.
355
- * Accounts without email are always preserved.
356
- * @param accounts - Array of accounts to deduplicate
357
- * @returns New array with email duplicates removed
358
- */
359
- export function deduplicateAccountsByEmail(accounts) {
360
- const emailToNewestIndex = new Map();
361
- const indicesToKeep = new Set();
362
- for (let i = 0; i < accounts.length; i += 1) {
363
- const account = accounts[i];
364
- if (!account)
365
- continue;
366
- const organizationId = account.organizationId?.trim();
367
- if (organizationId) {
368
- indicesToKeep.add(i);
369
- continue;
370
- }
371
- const accountId = account.accountId?.trim();
372
- if (accountId) {
373
- indicesToKeep.add(i);
374
- continue;
375
- }
376
- const email = account.email?.trim();
377
- if (!email) {
378
- indicesToKeep.add(i);
379
- continue;
380
- }
381
- const existingIndex = emailToNewestIndex.get(email);
382
- if (existingIndex === undefined) {
383
- emailToNewestIndex.set(email, i);
384
- continue;
385
- }
386
- const existing = accounts[existingIndex];
387
- // istanbul ignore next -- defensive code: existingIndex always refers to valid account
388
- if (!existing) {
389
- emailToNewestIndex.set(email, i);
390
- continue;
391
- }
392
- const existingLastUsed = existing.lastUsed || 0;
393
- const candidateLastUsed = account.lastUsed || 0;
394
- const existingAddedAt = existing.addedAt || 0;
395
- const candidateAddedAt = account.addedAt || 0;
396
- const isNewer = candidateLastUsed > existingLastUsed ||
397
- (candidateLastUsed === existingLastUsed && candidateAddedAt > existingAddedAt);
398
- if (isNewer) {
399
- emailToNewestIndex.set(email, i);
400
- }
401
- }
402
- for (const idx of emailToNewestIndex.values()) {
403
- indicesToKeep.add(idx);
404
- }
405
- const result = [];
406
- for (let i = 0; i < accounts.length; i += 1) {
407
- if (indicesToKeep.has(i)) {
408
- const account = accounts[i];
409
- if (account)
410
- result.push(account);
411
- }
412
- }
413
- return result;
414
- }
415
- function isRecord(value) {
416
- return !!value && typeof value === "object" && !Array.isArray(value);
417
- }
418
- function clampIndex(index, length) {
419
- if (length <= 0)
420
- return 0;
421
- return Math.max(0, Math.min(index, length - 1));
422
- }
423
- function toAccountIdentityKeys(account) {
424
- const keys = [];
425
- const organizationId = typeof account.organizationId === "string" ? account.organizationId.trim() : "";
426
- if (organizationId) {
427
- keys.push(`organizationId:${organizationId}`);
428
- }
429
- const accountId = typeof account.accountId === "string" ? account.accountId.trim() : "";
430
- if (accountId) {
431
- keys.push(`accountId:${accountId}`);
432
- }
433
- const refreshToken = typeof account.refreshToken === "string" ? account.refreshToken.trim() : "";
434
- if (refreshToken) {
435
- keys.push(`refreshToken:${refreshToken}`);
436
- }
437
- return keys;
438
- }
439
- function toAccountIdentityKey(account) {
440
- return toAccountIdentityKeys(account)[0];
441
- }
442
- function extractActiveKeys(accounts, activeIndex) {
443
- const candidate = accounts[activeIndex];
444
- if (!isRecord(candidate))
445
- return [];
446
- return toAccountIdentityKeys({
447
- organizationId: typeof candidate.organizationId === "string" ? candidate.organizationId : undefined,
448
- accountId: typeof candidate.accountId === "string" ? candidate.accountId : undefined,
449
- refreshToken: typeof candidate.refreshToken === "string" ? candidate.refreshToken : "",
450
- });
451
- }
452
- function findAccountIndexByIdentityKeys(accounts, identityKeys) {
453
- if (identityKeys.length === 0)
454
- return -1;
455
- for (const identityKey of identityKeys) {
456
- const idx = accounts.findIndex((account) => toAccountIdentityKeys(account).includes(identityKey));
457
- if (idx >= 0) {
458
- return idx;
459
- }
460
- }
461
- return -1;
462
- }
463
- /**
464
- * Normalizes and validates account storage data, migrating from v1 to v3 if needed.
465
- * Handles deduplication, index clamping, and per-family active index mapping.
466
- * @param data - Raw storage data (unknown format)
467
- * @returns Normalized AccountStorageV3 or null if invalid
468
- */
469
- export function normalizeAccountStorage(data) {
470
- if (!isRecord(data)) {
471
- log.warn("Invalid storage format, ignoring");
472
- return null;
473
- }
474
- if (data.version !== 1 && data.version !== 3) {
475
- log.warn("Unknown storage version, ignoring", {
476
- version: data.version,
477
- });
478
- return null;
479
- }
480
- const rawAccounts = data.accounts;
481
- if (!Array.isArray(rawAccounts)) {
482
- log.warn("Invalid storage format, ignoring");
483
- return null;
484
- }
485
- const activeIndexValue = typeof data.activeIndex === "number" && Number.isFinite(data.activeIndex)
486
- ? data.activeIndex
487
- : 0;
488
- const rawActiveIndex = clampIndex(activeIndexValue, rawAccounts.length);
489
- const activeKeys = extractActiveKeys(rawAccounts, rawActiveIndex);
490
- const fromVersion = data.version;
491
- const baseStorage = fromVersion === 1
492
- ? migrateV1ToV3(data)
493
- : data;
494
- const validAccounts = rawAccounts.filter((account) => isRecord(account) && typeof account.refreshToken === "string" && !!account.refreshToken.trim());
495
- const deduplicatedAccounts = deduplicateAccountsForStorage(validAccounts);
496
- const activeIndex = (() => {
497
- if (deduplicatedAccounts.length === 0)
498
- return 0;
499
- if (activeKeys.length > 0) {
500
- const mappedIndex = findAccountIndexByIdentityKeys(deduplicatedAccounts, activeKeys);
501
- if (mappedIndex >= 0)
502
- return mappedIndex;
503
- }
504
- return clampIndex(rawActiveIndex, deduplicatedAccounts.length);
505
- })();
506
- const activeIndexByFamily = {};
507
- const rawFamilyIndices = isRecord(baseStorage.activeIndexByFamily)
508
- ? baseStorage.activeIndexByFamily
509
- : {};
510
- for (const family of MODEL_FAMILIES) {
511
- const rawIndexValue = rawFamilyIndices[family];
512
- const rawIndex = typeof rawIndexValue === "number" && Number.isFinite(rawIndexValue)
513
- ? rawIndexValue
514
- : rawActiveIndex;
515
- const clampedRawIndex = clampIndex(rawIndex, rawAccounts.length);
516
- const familyKeys = extractActiveKeys(rawAccounts, clampedRawIndex);
517
- let mappedIndex = clampIndex(rawIndex, deduplicatedAccounts.length);
518
- if (familyKeys.length > 0 && deduplicatedAccounts.length > 0) {
519
- const idx = findAccountIndexByIdentityKeys(deduplicatedAccounts, familyKeys);
520
- if (idx >= 0) {
521
- mappedIndex = idx;
522
- }
523
- }
524
- activeIndexByFamily[family] = mappedIndex;
525
- }
526
- return {
527
- version: 3,
528
- accounts: deduplicatedAccounts,
529
- activeIndex,
530
- activeIndexByFamily,
531
- };
532
- }
533
- /**
534
- * Loads OAuth accounts from disk storage.
535
- * Automatically migrates v1 storage to v3 format if needed.
536
- * @returns AccountStorageV3 if file exists and is valid, null otherwise
537
- */
538
- export async function loadAccounts() {
539
- return withStorageLock(async () => loadAccountsInternal(saveAccountsUnlocked));
540
- }
541
- /**
542
- * Resolves the global (non-project) account storage path.
543
- */
544
- function getGlobalAccountsStoragePath() {
545
- return join(getConfigDir(), ACCOUNTS_FILE_NAME);
546
- }
547
- function getLegacyGlobalAccountsStoragePath() {
548
- return join(getConfigDir(), LEGACY_ACCOUNTS_FILE_NAME);
549
- }
550
- async function migrateLegacyGlobalStorageIfNeeded() {
551
- const nextPath = getGlobalAccountsStoragePath();
552
- const persistGlobalStorage = async (storage) => {
553
- await writeAccountsToPathUnlocked(nextPath, storage);
554
- };
555
- return migrateStorageFileIfNeeded(getLegacyGlobalAccountsStoragePath(), nextPath, persistGlobalStorage, "global account storage");
556
- }
557
- /**
558
- * Returns true when project-scoped storage is active and a global fallback is meaningful.
559
- */
560
- function shouldUseProjectGlobalFallback() {
561
- return Boolean(currentStoragePath && currentProjectRoot);
562
- }
563
- /**
564
- * Loads account data from global storage as a fallback when project storage is missing.
565
- * Returns null for missing/unusable global storage and never throws to callers.
566
- */
567
- async function loadGlobalAccountsFallback() {
568
- if (!shouldUseProjectGlobalFallback() || !currentStoragePath) {
569
- return null;
570
- }
571
- const migrated = await migrateLegacyGlobalStorageIfNeeded();
572
- if (migrated) {
573
- return migrated;
574
- }
575
- const globalStoragePath = getGlobalAccountsStoragePath();
576
- if (globalStoragePath === currentStoragePath) {
577
- return null;
578
- }
579
- try {
580
- const content = await fs.readFile(globalStoragePath, "utf-8");
581
- const data = JSON.parse(content);
582
- const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data);
583
- if (schemaErrors.length > 0) {
584
- log.warn("Global account storage schema validation warnings", {
585
- path: globalStoragePath,
586
- errors: schemaErrors.slice(0, 5),
587
- });
588
- }
589
- const normalized = normalizeAccountStorage(data);
590
- if (!normalized)
591
- return null;
592
- log.info("Loaded global account storage as project fallback", {
593
- from: globalStoragePath,
594
- to: currentStoragePath,
595
- accounts: normalized.accounts.length,
596
- });
597
- return normalized;
598
- }
599
- catch (error) {
600
- const code = error.code;
601
- if (code !== "ENOENT") {
602
- log.warn("Failed to load global fallback account storage", {
603
- from: globalStoragePath,
604
- to: currentStoragePath,
605
- error: String(error),
606
- });
607
- }
608
- return null;
609
- }
610
- }
611
- /**
612
- * Core account-loading routine shared by normal reads and transactional storage handlers.
613
- * Handles schema normalization, legacy migration, and optional fallback seeding.
614
- */
615
- async function loadAccountsInternal(persistMigration) {
616
- try {
617
- const path = getStoragePath();
618
- const content = await fs.readFile(path, "utf-8");
619
- const data = JSON.parse(content);
620
- const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data);
621
- if (schemaErrors.length > 0) {
622
- log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) });
623
- }
624
- const normalized = normalizeAccountStorage(data);
625
- const storedVersion = isRecord(data) ? data.version : undefined;
626
- if (normalized && storedVersion !== normalized.version) {
627
- log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version });
628
- if (persistMigration) {
629
- try {
630
- await persistMigration(normalized);
631
- }
632
- catch (saveError) {
633
- log.warn("Failed to persist migrated storage", { error: String(saveError) });
634
- }
635
- }
636
- }
637
- return normalized;
638
- }
639
- catch (error) {
640
- const code = error.code;
641
- if (code === "ENOENT") {
642
- const migrated = persistMigration
643
- ? await migrateLegacyProjectStorageIfNeeded(persistMigration)
644
- : null;
645
- if (migrated)
646
- return migrated;
647
- if (!shouldUseProjectGlobalFallback()) {
648
- const migratedGlobal = persistMigration
649
- ? await migrateLegacyGlobalStorageIfNeeded()
650
- : null;
651
- if (migratedGlobal)
652
- return migratedGlobal;
653
- return null;
654
- }
655
- const globalFallback = await loadGlobalAccountsFallback();
656
- if (!globalFallback)
657
- return null;
658
- if (persistMigration) {
659
- const seedPath = getStoragePath();
660
- try {
661
- await fs.access(seedPath);
662
- return globalFallback;
663
- }
664
- catch (accessError) {
665
- const accessCode = accessError.code;
666
- if (accessCode !== "ENOENT") {
667
- log.warn("Failed to inspect project seed path before fallback seeding", {
668
- path: seedPath,
669
- error: String(accessError),
670
- });
671
- return globalFallback;
672
- }
673
- // File is missing; proceed with seed write.
674
- }
675
- try {
676
- await persistMigration(globalFallback);
677
- log.info("Seeded project account storage from global fallback", {
678
- path: seedPath,
679
- accounts: globalFallback.accounts.length,
680
- });
681
- }
682
- catch (persistError) {
683
- log.warn("Failed to seed project storage from global fallback", {
684
- path: seedPath,
685
- error: String(persistError),
686
- });
687
- }
688
- }
689
- return globalFallback;
690
- }
691
- log.error("Failed to load account storage", { error: String(error) });
692
- return null;
693
- }
694
- }
695
- /**
696
- * Writes account storage without acquiring the outer storage mutex.
697
- * Callers must already be inside withStorageLock when using this helper directly.
698
- */
699
- async function writeAccountsToPathUnlocked(path, storage) {
700
- const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
701
- const tempPath = `${path}.${uniqueSuffix}.tmp`;
702
- try {
703
- await fs.mkdir(dirname(path), { recursive: true });
704
- await ensureGitignore(path);
705
- // Normalize before persisting so every write path enforces dedup semantics
706
- // (exact identity dedupe plus legacy email dedupe for identity-less records).
707
- const normalizedStorage = normalizeAccountStorage(storage) ?? storage;
708
- const content = JSON.stringify(normalizedStorage, null, 2);
709
- await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
710
- const stats = await fs.stat(tempPath);
711
- if (stats.size === 0) {
712
- const emptyError = Object.assign(new Error("File written but size is 0"), { code: "EEMPTY" });
713
- throw emptyError;
714
- }
715
- await renameWithWindowsRetry(tempPath, path);
716
- }
717
- catch (error) {
718
- try {
719
- await fs.unlink(tempPath);
720
- }
721
- catch {
722
- // Ignore cleanup failure.
723
- }
724
- const err = error;
725
- const code = err?.code || "UNKNOWN";
726
- const hint = formatStorageErrorHint(error, path);
727
- log.error("Failed to save accounts", {
728
- path,
729
- code,
730
- message: err?.message,
731
- hint,
732
- });
733
- throw new StorageError(`Failed to save accounts: ${err?.message || "Unknown error"}`, code, path, hint, err instanceof Error ? err : undefined);
734
- }
735
- }
736
- async function saveAccountsUnlocked(storage) {
737
- await writeAccountsToPathUnlocked(getStoragePath(), storage);
738
- }
739
- /**
740
- * Executes a read-modify-write transaction under the storage lock and exposes
741
- * an unlocked persist callback so nested save operations do not deadlock.
742
- */
743
- export async function withAccountStorageTransaction(handler) {
744
- return withStorageLock(async () => {
745
- const current = await loadAccountsInternal(saveAccountsUnlocked);
746
- return handler(current, saveAccountsUnlocked);
747
- });
748
- }
749
- /**
750
- * Persists account storage to disk using atomic write (temp file + rename).
751
- * Creates the .opencode directory if it doesn't exist.
752
- * Verifies file was written correctly and provides detailed error messages.
753
- * @param storage - Account storage data to save
754
- * @throws StorageError with platform-aware hints on failure
755
- */
756
- export async function saveAccounts(storage) {
757
- return withStorageLock(async () => {
758
- await saveAccountsUnlocked(storage);
759
- });
760
- }
761
- /**
762
- * Deletes the account storage file from disk.
763
- * Silently ignores if file doesn't exist.
764
- */
765
- export async function clearAccounts() {
766
- return withStorageLock(async () => {
767
- try {
768
- const path = getStoragePath();
769
- await fs.unlink(path);
770
- }
771
- catch (error) {
772
- const code = error.code;
773
- if (code !== "ENOENT") {
774
- log.error("Failed to clear account storage", { error: String(error) });
775
- }
776
- }
777
- });
778
- }
779
- function normalizeFlaggedStorage(data) {
780
- if (!isRecord(data) || data.version !== 1 || !Array.isArray(data.accounts)) {
781
- return { version: 1, accounts: [] };
782
- }
783
- const byIdentityKey = new Map();
784
- for (const rawAccount of data.accounts) {
785
- if (!isRecord(rawAccount))
786
- continue;
787
- const refreshToken = typeof rawAccount.refreshToken === "string" ? rawAccount.refreshToken.trim() : "";
788
- if (!refreshToken)
789
- continue;
790
- const flaggedAt = typeof rawAccount.flaggedAt === "number" ? rawAccount.flaggedAt : Date.now();
791
- const isAccountIdSource = (value) => value === "token" || value === "id_token" || value === "org" || value === "manual";
792
- const isSwitchReason = (value) => value === "rate-limit" || value === "initial" || value === "rotation";
793
- const isCooldownReason = (value) => value === "auth-failure" || value === "network-error";
794
- const normalizeTags = (value) => {
795
- if (!Array.isArray(value))
796
- return undefined;
797
- const normalized = value
798
- .filter((entry) => typeof entry === "string")
799
- .map((entry) => entry.trim().toLowerCase())
800
- .filter((entry) => entry.length > 0);
801
- return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
802
- };
803
- let rateLimitResetTimes;
804
- if (isRecord(rawAccount.rateLimitResetTimes)) {
805
- const normalizedRateLimits = {};
806
- for (const [key, value] of Object.entries(rawAccount.rateLimitResetTimes)) {
807
- if (typeof value === "number") {
808
- normalizedRateLimits[key] = value;
809
- }
810
- }
811
- if (Object.keys(normalizedRateLimits).length > 0) {
812
- rateLimitResetTimes = normalizedRateLimits;
813
- }
814
- }
815
- const accountIdSource = isAccountIdSource(rawAccount.accountIdSource)
816
- ? rawAccount.accountIdSource
817
- : undefined;
818
- const lastSwitchReason = isSwitchReason(rawAccount.lastSwitchReason)
819
- ? rawAccount.lastSwitchReason
820
- : undefined;
821
- const cooldownReason = isCooldownReason(rawAccount.cooldownReason)
822
- ? rawAccount.cooldownReason
823
- : undefined;
824
- const accountTags = normalizeTags(rawAccount.accountTags);
825
- const accountNote = typeof rawAccount.accountNote === "string" && rawAccount.accountNote.trim()
826
- ? rawAccount.accountNote.trim()
827
- : undefined;
828
- const normalized = {
829
- refreshToken,
830
- addedAt: typeof rawAccount.addedAt === "number" ? rawAccount.addedAt : flaggedAt,
831
- lastUsed: typeof rawAccount.lastUsed === "number" ? rawAccount.lastUsed : flaggedAt,
832
- organizationId: typeof rawAccount.organizationId === "string" ? rawAccount.organizationId : undefined,
833
- accountId: typeof rawAccount.accountId === "string" ? rawAccount.accountId : undefined,
834
- accountIdSource,
835
- accountLabel: typeof rawAccount.accountLabel === "string" ? rawAccount.accountLabel : undefined,
836
- accountTags,
837
- accountNote,
838
- email: typeof rawAccount.email === "string" ? rawAccount.email : undefined,
839
- enabled: typeof rawAccount.enabled === "boolean" ? rawAccount.enabled : undefined,
840
- lastSwitchReason,
841
- rateLimitResetTimes,
842
- coolingDownUntil: typeof rawAccount.coolingDownUntil === "number" ? rawAccount.coolingDownUntil : undefined,
843
- cooldownReason,
844
- flaggedAt,
845
- flaggedReason: typeof rawAccount.flaggedReason === "string" ? rawAccount.flaggedReason : undefined,
846
- lastError: typeof rawAccount.lastError === "string" ? rawAccount.lastError : undefined,
847
- };
848
- // Keep flagged dedup aligned with active cleanup so sibling workspaces only
849
- // collapse when they resolve to the same shared workspace identity.
850
- byIdentityKey.set(getWorkspaceIdentityKey(normalized), normalized);
851
- }
852
- return {
853
- version: 1,
854
- accounts: Array.from(byIdentityKey.values()),
855
- };
856
- }
857
- async function loadFlaggedAccountsUnlocked(saveUnlocked) {
858
- const path = getFlaggedAccountsPath();
859
- const empty = { version: 1, accounts: [] };
860
- try {
861
- const content = await fs.readFile(path, "utf-8");
862
- const data = JSON.parse(content);
863
- return normalizeFlaggedStorage(data);
864
- }
865
- catch (error) {
866
- const code = error.code;
867
- if (code !== "ENOENT") {
868
- log.error("Failed to load flagged account storage", { path, error: String(error) });
869
- return empty;
870
- }
871
- }
872
- for (const legacyPath of [getLegacyFlaggedAccountsPath(), getLegacyBlockedAccountsPath()]) {
873
- if (!existsSync(legacyPath)) {
874
- continue;
875
- }
876
- try {
877
- const legacyContent = await fs.readFile(legacyPath, "utf-8");
878
- const legacyData = JSON.parse(legacyContent);
879
- const migrated = normalizeFlaggedStorage(legacyData);
880
- if (migrated.accounts.length > 0) {
881
- await saveUnlocked(migrated);
882
- }
883
- try {
884
- await fs.unlink(legacyPath);
885
- }
886
- catch {
887
- // Best effort cleanup.
888
- }
889
- log.info("Migrated legacy flagged account storage", {
890
- from: legacyPath,
891
- to: path,
892
- accounts: migrated.accounts.length,
893
- });
894
- return migrated;
895
- }
896
- catch (error) {
897
- log.error("Failed to migrate legacy flagged account storage", {
898
- from: legacyPath,
899
- to: path,
900
- error: String(error),
901
- });
902
- return empty;
903
- }
904
- }
905
- return empty;
906
- }
907
- export async function loadFlaggedAccounts() {
908
- return withStorageLock(async () => loadFlaggedAccountsUnlocked(saveFlaggedAccountsUnlocked));
909
- }
910
- async function saveFlaggedAccountsUnlocked(storage) {
911
- const path = getFlaggedAccountsPath();
912
- const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
913
- const tempPath = `${path}.${uniqueSuffix}.tmp`;
914
- try {
915
- await fs.mkdir(dirname(path), { recursive: true });
916
- const content = JSON.stringify(normalizeFlaggedStorage(storage), null, 2);
917
- await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
918
- await renameWithWindowsRetry(tempPath, path);
919
- }
920
- catch (error) {
921
- try {
922
- await fs.unlink(tempPath);
923
- }
924
- catch {
925
- // Ignore cleanup failures.
926
- }
927
- log.error("Failed to save flagged account storage", { path, error: String(error) });
928
- throw error;
929
- }
930
- }
931
- /**
932
- * Executes a read-modify-write transaction for flagged account storage under the
933
- * shared storage lock so concurrent callers cannot lose updates.
934
- */
935
- export async function withFlaggedAccountStorageTransaction(handler) {
936
- return withStorageLock(async () => {
937
- const current = await loadFlaggedAccountsUnlocked(saveFlaggedAccountsUnlocked);
938
- return handler(current, saveFlaggedAccountsUnlocked);
939
- });
940
- }
941
- export async function saveFlaggedAccounts(storage) {
942
- return withStorageLock(async () => {
943
- await saveFlaggedAccountsUnlocked(storage);
944
- });
945
- }
946
- export async function clearFlaggedAccounts() {
947
- return withStorageLock(async () => {
948
- try {
949
- await fs.unlink(getFlaggedAccountsPath());
950
- }
951
- catch (error) {
952
- const code = error.code;
953
- if (code !== "ENOENT") {
954
- log.error("Failed to clear flagged account storage", { error: String(error) });
955
- }
956
- }
957
- });
958
- }
959
- function formatBackupTimestamp(date = new Date()) {
960
- const yyyy = String(date.getFullYear());
961
- const mm = String(date.getMonth() + 1).padStart(2, "0");
962
- const dd = String(date.getDate()).padStart(2, "0");
963
- const hh = String(date.getHours()).padStart(2, "0");
964
- const min = String(date.getMinutes()).padStart(2, "0");
965
- const ss = String(date.getSeconds()).padStart(2, "0");
966
- const mmm = String(date.getMilliseconds()).padStart(3, "0");
967
- return `${yyyy}${mm}${dd}-${hh}${min}${ss}${mmm}`;
968
- }
969
- function sanitizeBackupPrefix(prefix) {
970
- const trimmed = prefix.trim();
971
- const safe = trimmed
972
- .replace(/[^a-zA-Z0-9_-]+/g, "-")
973
- .replace(/-+/g, "-")
974
- .replace(/^-+|-+$/g, "");
975
- return safe.length > 0 ? safe : "codex-backup";
976
- }
977
- export function createTimestampedBackupPath(prefix = "codex-backup") {
978
- const storagePath = getStoragePath();
979
- const backupDir = join(dirname(storagePath), "backups");
980
- const safePrefix = sanitizeBackupPrefix(prefix);
981
- const nonce = randomBytes(3).toString("hex");
982
- return join(backupDir, `${safePrefix}-${formatBackupTimestamp()}-${nonce}.json`);
983
- }
984
- async function readAndNormalizeImportFile(filePath) {
985
- const resolvedPath = resolvePath(filePath);
986
- if (!existsSync(resolvedPath)) {
987
- throw new Error(`Import file not found: ${resolvedPath}`);
988
- }
989
- const content = await fs.readFile(resolvedPath, "utf-8");
990
- let imported;
991
- try {
992
- imported = JSON.parse(content);
993
- }
994
- catch {
995
- throw new Error(`Invalid JSON in import file: ${resolvedPath}`);
996
- }
997
- const normalized = normalizeAccountStorage(imported);
998
- if (!normalized) {
999
- throw new Error("Invalid account storage format");
1000
- }
1001
- return { resolvedPath, normalized };
1002
- }
1003
- function analyzeImportedAccounts(existingAccounts, importedAccounts) {
1004
- const merged = [...existingAccounts, ...importedAccounts];
1005
- const accounts = deduplicateAccountsForStorage(merged);
1006
- if (accounts.length > ACCOUNT_LIMITS.MAX_ACCOUNTS) {
1007
- throw new Error(`Import would exceed maximum of ${ACCOUNT_LIMITS.MAX_ACCOUNTS} accounts (would have ${accounts.length})`);
1008
- }
1009
- const imported = Math.max(0, accounts.length - existingAccounts.length);
1010
- const skipped = Math.max(0, importedAccounts.length - imported);
1011
- return {
1012
- accounts,
1013
- imported,
1014
- total: accounts.length,
1015
- skipped,
1016
- };
1017
- }
1018
- /**
1019
- * Import preview/apply analysis is pure in-memory work: it does not touch disk
1020
- * and it does not log token or workspace values. The surrounding
1021
- * `withAccountStorageTransaction` caller keeps Windows lock-retry and
1022
- * serialized read-modify-write behavior; see `test/storage.test.ts` for the
1023
- * overlapping transaction regression and pre-import backup lock coverage.
1024
- */
1025
- export async function previewImportAccounts(filePath) {
1026
- const { normalized } = await readAndNormalizeImportFile(filePath);
1027
- return withAccountStorageTransaction((existing) => {
1028
- const existingAccounts = existing?.accounts ?? [];
1029
- const analysis = analyzeImportedAccounts(existingAccounts, normalized.accounts);
1030
- return Promise.resolve({
1031
- imported: analysis.imported,
1032
- total: analysis.total,
1033
- skipped: analysis.skipped,
1034
- });
1035
- });
1036
- }
1037
- /**
1038
- * Exports current accounts to a JSON file for backup/migration.
1039
- * @param filePath - Destination file path
1040
- * @param force - If true, overwrite existing file (default: true)
1041
- * @throws Error if file exists and force is false, or if no accounts to export
1042
- */
1043
- export async function exportAccounts(filePath, force = true) {
1044
- const resolvedPath = resolvePath(filePath);
1045
- if (!force && existsSync(resolvedPath)) {
1046
- throw new Error(`File already exists: ${resolvedPath}`);
1047
- }
1048
- const storage = await withAccountStorageTransaction((current) => Promise.resolve(current));
1049
- if (!storage || storage.accounts.length === 0) {
1050
- throw new Error("No accounts to export");
1051
- }
1052
- await fs.mkdir(dirname(resolvedPath), { recursive: true });
1053
- const content = JSON.stringify(storage, null, 2);
1054
- await fs.writeFile(resolvedPath, content, { encoding: "utf-8", mode: 0o600 });
1055
- log.info("Exported accounts", { path: resolvedPath, count: storage.accounts.length });
1056
- }
1057
- /**
1058
- * Imports accounts from a JSON file, merging with existing accounts.
1059
- * Deduplicates by identity key first (organizationId -> accountId -> refreshToken),
1060
- * then applies legacy email dedupe only to entries without organizationId/accountId.
1061
- * @param filePath - Source file path
1062
- * @throws Error if file is invalid or would exceed MAX_ACCOUNTS
1063
- */
1064
- export async function importAccounts(filePath, options = {}) {
1065
- const { resolvedPath, normalized } = await readAndNormalizeImportFile(filePath);
1066
- const backupMode = options.backupMode ?? "none";
1067
- const backupPrefix = options.preImportBackupPrefix ?? "codex-pre-import-backup";
1068
- const { imported: importedCount, total, skipped: skippedCount, backupStatus, backupPath, backupError, } = await withAccountStorageTransaction(async (existing, persist) => {
1069
- const existingStorage = existing ??
1070
- {
1071
- version: 3,
1072
- accounts: [],
1073
- activeIndex: 0,
1074
- activeIndexByFamily: {},
1075
- };
1076
- const existingAccounts = existingStorage.accounts;
1077
- const existingActiveIndex = existingStorage.activeIndex;
1078
- const clampedExistingActiveIndex = clampIndex(existingActiveIndex, existingAccounts.length);
1079
- const existingActiveKeys = extractActiveKeys(existingAccounts, clampedExistingActiveIndex);
1080
- const existingActiveIndexByFamily = existingStorage.activeIndexByFamily ?? {};
1081
- let backupStatus = "skipped";
1082
- let backupPath;
1083
- let backupError;
1084
- let backupLogError;
1085
- if (backupMode !== "none" && existingAccounts.length > 0) {
1086
- backupPath = createTimestampedBackupPath(backupPrefix);
1087
- try {
1088
- await writePreImportBackupFile(backupPath, existingStorage);
1089
- backupStatus = "created";
1090
- }
1091
- catch (error) {
1092
- backupStatus = "failed";
1093
- backupError = error instanceof Error ? error.message : String(error);
1094
- const backupCode = error?.code;
1095
- backupLogError = backupCode
1096
- ? `pre-import backup failed (${backupCode})`
1097
- : "pre-import backup failed";
1098
- if (backupMode === "required") {
1099
- throw new Error(backupCode
1100
- ? `Pre-import backup failed (${backupCode})`
1101
- : "Pre-import backup failed");
1102
- }
1103
- log.warn("Pre-import backup failed; continuing import apply", {
1104
- backupFile: backupPath ? basename(backupPath) : undefined,
1105
- error: backupLogError,
1106
- });
1107
- }
1108
- }
1109
- const analysis = analyzeImportedAccounts(existingAccounts, normalized.accounts);
1110
- const deduplicatedAccounts = analysis.accounts;
1111
- const mappedActiveIndex = (() => {
1112
- if (deduplicatedAccounts.length === 0)
1113
- return 0;
1114
- if (existingActiveKeys.length > 0) {
1115
- const idx = findAccountIndexByIdentityKeys(deduplicatedAccounts, existingActiveKeys);
1116
- if (idx >= 0)
1117
- return idx;
1118
- }
1119
- return clampIndex(clampedExistingActiveIndex, deduplicatedAccounts.length);
1120
- })();
1121
- const activeIndexByFamily = {};
1122
- for (const family of MODEL_FAMILIES) {
1123
- const rawFamilyIndex = existingActiveIndexByFamily[family];
1124
- const familyIndex = typeof rawFamilyIndex === "number" && Number.isFinite(rawFamilyIndex)
1125
- ? rawFamilyIndex
1126
- : clampedExistingActiveIndex;
1127
- const familyKeys = extractActiveKeys(existingAccounts, clampIndex(familyIndex, existingAccounts.length));
1128
- if (familyKeys.length > 0) {
1129
- const idx = findAccountIndexByIdentityKeys(deduplicatedAccounts, familyKeys);
1130
- activeIndexByFamily[family] = idx >= 0 ? idx : mappedActiveIndex;
1131
- continue;
1132
- }
1133
- activeIndexByFamily[family] = mappedActiveIndex;
1134
- }
1135
- const newStorage = {
1136
- version: 3,
1137
- accounts: deduplicatedAccounts,
1138
- activeIndex: mappedActiveIndex,
1139
- activeIndexByFamily,
1140
- };
1141
- await persist(newStorage);
1142
- return {
1143
- imported: analysis.imported,
1144
- total: analysis.total,
1145
- skipped: analysis.skipped,
1146
- backupStatus,
1147
- backupPath,
1148
- backupError,
1149
- };
1150
- });
1151
- log.info("Imported accounts", {
1152
- path: resolvedPath,
1153
- imported: importedCount,
1154
- skipped: skippedCount,
1155
- total,
1156
- backupStatus,
1157
- backupFile: backupPath ? basename(backupPath) : undefined,
1158
- backupError: backupError ? "available on command result" : undefined,
1159
- });
1160
- return {
1161
- imported: importedCount,
1162
- total,
1163
- skipped: skippedCount,
1164
- backupStatus,
1165
- backupPath,
1166
- backupError,
1167
- };
1168
- }
2
+ * Barrel for the account-storage layer.
3
+ *
4
+ * RC-2 split the original 1400-line `lib/storage.ts` into focused modules
5
+ * under `lib/storage/`. This file exists to keep the public surface stable
6
+ * so every consumer that imports from `./storage` / `../storage` resolves
7
+ * exactly as it did before the refactor.
8
+ *
9
+ * See `docs/audits/07-refactoring-plan.md#rc-2` and
10
+ * `docs/audits/16-code-health.md` for the motivation.
11
+ */
12
+ // --- Errors ------------------------------------------------------------------
13
+ export { StorageError, formatStorageErrorHint } from "./storage/errors.js";
14
+ // --- Path + state management -------------------------------------------------
15
+ export { setStoragePath, setStoragePathDirect, getStoragePath, } from "./storage/state.js";
16
+ // --- Identity + dedup helpers ------------------------------------------------
17
+ export { getWorkspaceIdentityKey, deduplicateAccounts, deduplicateAccountsByEmail, } from "./storage/identity.js";
18
+ // --- Normalization -----------------------------------------------------------
19
+ export { normalizeAccountStorage } from "./storage/normalize.js";
20
+ // --- Account load/save + transaction -----------------------------------------
21
+ export { loadAccounts, saveAccounts, clearAccounts, withAccountStorageTransaction, } from "./storage/load-save.js";
22
+ export { getFlaggedAccountsPath, loadFlaggedAccounts, saveFlaggedAccounts, clearFlaggedAccounts, withFlaggedAccountStorageTransaction, } from "./storage/flagged.js";
23
+ // --- Backups -----------------------------------------------------------------
24
+ export { createTimestampedBackupPath } from "./storage/backup.js";
25
+ export { exportAccounts, importAccounts, previewImportAccounts, } from "./storage/export-import.js";
1169
26
  //# sourceMappingURL=storage.js.map