@wenathlan/devthink 2.0.14 → 2.0.19

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 (444) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/{dist/bridge.d.ts → bridge.d.ts} +1 -1
  3. package/bridge.d.ts.map +1 -0
  4. package/bridge.js +1877 -0
  5. package/bun.d.ts.map +1 -0
  6. package/bun.js +52329 -0
  7. package/caps/background.json +712 -0
  8. package/caps/cli.json +360 -0
  9. package/caps/library.json +3611 -0
  10. package/caps/mcp.json +126 -0
  11. package/caps/pagebridge.json +377 -0
  12. package/caps/popup.json +51 -0
  13. package/caps/sidepanel.json +522 -0
  14. package/checksums.txt +142 -0
  15. package/cli.d.ts.map +1 -0
  16. package/{dist/cli.js → cli.js} +2 -2
  17. package/companion.d.ts.map +1 -0
  18. package/companion.js +389 -0
  19. package/crossbrowser.d.ts.map +1 -0
  20. package/crossbrowser.js +1191 -0
  21. package/dashdone.d.ts.map +1 -0
  22. package/dashdone.js +368 -0
  23. package/deno.d.ts.map +1 -0
  24. package/deno.js +52325 -0
  25. package/devthink.umd.js +52346 -0
  26. package/fixtures/bun.ts +7 -0
  27. package/fixtures/cjs.js +6 -0
  28. package/fixtures/deno.ts +6 -0
  29. package/fixtures/example-org-pagestate.json +22 -0
  30. package/fixtures/importers/automa-workflow.json +18 -0
  31. package/fixtures/importers/selenium-side.json +20 -0
  32. package/fixtures/importers/tabular-plan.csv +7 -0
  33. package/fixtures/importers/uivision-macro.json +10 -0
  34. package/fixtures/importers/v1-plan.json +13 -0
  35. package/fixtures/mcp-client.mjs +14 -0
  36. package/fixtures/pages/checkout.html +192 -0
  37. package/fixtures/pages/dashboard.html +110 -0
  38. package/fixtures/pages/feed.html +113 -0
  39. package/fixtures/pages/product-grid.html +150 -0
  40. package/fixtures/plans/form-inventory-plan.json +11 -0
  41. package/fixtures/plans/release-notes-plan.json +11 -0
  42. package/fixtures/recipes/agentscompete.json +11 -0
  43. package/fixtures/recipes/agentsforms.json +11 -0
  44. package/fixtures/recipes/agentsmonitor.json +11 -0
  45. package/fixtures/recipes/agentsreview.json +29 -0
  46. package/fixtures/recipes/agentswarm.json +11 -0
  47. package/fixtures/recipes/fillcalendar.json +12 -0
  48. package/fixtures/recipes/fillcheckout.json +13 -0
  49. package/fixtures/recipes/filldependentdropdowns.json +12 -0
  50. package/fixtures/recipes/fillfromcsv.json +13 -0
  51. package/fixtures/recipes/fillloginform.json +12 -0
  52. package/fixtures/recipes/fillmultistepwizard.json +13 -0
  53. package/fixtures/recipes/fillregistration.json +12 -0
  54. package/fixtures/recipes/fillsearchfilters.json +12 -0
  55. package/fixtures/recipes/filltypeahead.json +11 -0
  56. package/fixtures/recipes/monitoralerts.json +12 -0
  57. package/fixtures/recipes/monitoravailability.json +11 -0
  58. package/fixtures/recipes/monitornetworkquiet.json +12 -0
  59. package/fixtures/recipes/monitorpagechanges.json +11 -0
  60. package/fixtures/recipes/monitorpricechange.json +11 -0
  61. package/fixtures/recipes/monitorschedule.json +12 -0
  62. package/fixtures/recipes/scrapedynamicprices.json +13 -0
  63. package/fixtures/recipes/scrapeimagegallery.json +12 -0
  64. package/fixtures/recipes/scrapeinfinitefeed.json +13 -0
  65. package/fixtures/recipes/scrapenestedlists.json +12 -0
  66. package/fixtures/recipes/scrapeproductgrid.json +12 -0
  67. package/fixtures/recipes/scrapereviews.json +12 -0
  68. package/fixtures/recipes/scrapesearchresults.json +12 -0
  69. package/fixtures/recipes/scrapetablepagination.json +12 -0
  70. package/fixtures/recipes/testaccessibility.json +34 -0
  71. package/fixtures/recipes/testconsoleerrors.json +12 -0
  72. package/fixtures/recipes/testdeeplinks.json +12 -0
  73. package/fixtures/recipes/testformvalidation.json +13 -0
  74. package/fixtures/recipes/testlayoutshift.json +12 -0
  75. package/fixtures/recipes/testlinkcheck.json +12 -0
  76. package/fixtures/recipes/testloadtiming.json +12 -0
  77. package/fixtures/recipes/testvisualdiff.json +12 -0
  78. package/fixtures/workflows/changelog-digest-workflow.json +13 -0
  79. package/fixtures/workflows/form-survey-workflow.json +13 -0
  80. package/gallery.json +706 -0
  81. package/hardening.d.ts.map +1 -0
  82. package/hardening.js +188 -0
  83. package/headless.d.ts.map +1 -0
  84. package/headless.js +11522 -0
  85. package/http.d.ts.map +1 -0
  86. package/http.js +2165 -0
  87. package/index.cjs +54682 -0
  88. package/index.d.ts.map +1 -0
  89. package/index.js +52310 -0
  90. package/index.neutral.js +46343 -0
  91. package/{web/manifest.json → manifest.json} +1 -1
  92. package/mcp.d.ts.map +1 -0
  93. package/mcp.js +20515 -0
  94. package/memory.d.ts.map +1 -0
  95. package/{memory.ts → memory.js} +2310 -3834
  96. package/nativehost.template.json +10 -0
  97. package/node.cjs +54686 -0
  98. package/node.d.ts.map +1 -0
  99. package/pack.d.ts.map +1 -0
  100. package/pack.js +805 -0
  101. package/package.json +83 -176
  102. package/policy.d.ts.map +1 -0
  103. package/{policy.ts → policy.js} +4679 -5073
  104. package/progress.d.ts.map +1 -0
  105. package/progress.js +645 -0
  106. package/protocol.d.ts.map +1 -0
  107. package/protocol.js +8334 -0
  108. package/schemas/audit.schema.json +226 -0
  109. package/schemas/capability.schema.json +113 -0
  110. package/schemas/envelope.schema.json +162 -0
  111. package/schemas/memory.schema.json +51 -0
  112. package/schemas/observation.schema.json +113 -0
  113. package/schemas/plan.schema.json +208 -0
  114. package/schemas/progress.schema.json +126 -0
  115. package/schemas/proposal.schema.json +68 -0
  116. package/schemas/session.schema.json +82 -0
  117. package/schemas/tool.schema.json +94 -0
  118. package/server.d.ts.map +1 -0
  119. package/server.js +28877 -0
  120. package/umd-example.html +18 -0
  121. package/umd.d.ts.map +1 -0
  122. package/accounts.ts +0 -2087
  123. package/agent.ts +0 -2331
  124. package/antigravity.ts +0 -2139
  125. package/apifreeze.ts +0 -4146
  126. package/auth.ts +0 -642
  127. package/background.ts +0 -16837
  128. package/bridge.ts +0 -2080
  129. package/build.ts +0 -54
  130. package/bun.ts +0 -26
  131. package/capture.ts +0 -1614
  132. package/checksecrets.ts +0 -72
  133. package/cjs.ts +0 -9
  134. package/cli.ts +0 -3500
  135. package/commands.ts +0 -1101
  136. package/companion.ts +0 -450
  137. package/compatibility.ts +0 -37
  138. package/config.ts +0 -3137
  139. package/constants.ts +0 -943
  140. package/core.ts +0 -1473
  141. package/crossbrowser.ts +0 -1359
  142. package/dashdone.ts +0 -605
  143. package/data.ts +0 -943
  144. package/database.ts +0 -133
  145. package/debug.ts +0 -850
  146. package/deno.ts +0 -27
  147. package/devthink.ts +0 -707
  148. package/dist/accounts.d.ts +0 -409
  149. package/dist/agent.d.ts +0 -837
  150. package/dist/antigravity.d.ts +0 -380
  151. package/dist/apifreeze.d.ts +0 -113
  152. package/dist/auth.d.ts +0 -224
  153. package/dist/capture.d.ts +0 -595
  154. package/dist/cjs.d.ts +0 -6
  155. package/dist/commands.d.ts +0 -310
  156. package/dist/compatibility.d.ts +0 -17
  157. package/dist/config.d.ts +0 -1853
  158. package/dist/constants.d.ts +0 -637
  159. package/dist/core.d.ts +0 -361
  160. package/dist/data.d.ts +0 -330
  161. package/dist/database.d.ts +0 -24
  162. package/dist/debug.d.ts +0 -270
  163. package/dist/engine.d.ts +0 -74
  164. package/dist/environments.d.ts +0 -244
  165. package/dist/evidence.d.ts +0 -277
  166. package/dist/export.d.ts +0 -311
  167. package/dist/fingerprint.d.ts +0 -594
  168. package/dist/flow.d.ts +0 -300
  169. package/dist/gates.d.ts +0 -103
  170. package/dist/identity.d.ts +0 -51
  171. package/dist/ids.d.ts +0 -7
  172. package/dist/llm.d.ts +0 -652
  173. package/dist/models.d.ts +0 -1807
  174. package/dist/net.d.ts +0 -325
  175. package/dist/neutral.d.ts +0 -41
  176. package/dist/oauth.d.ts +0 -703
  177. package/dist/page.d.ts +0 -1166
  178. package/dist/perf.d.ts +0 -481
  179. package/dist/plan.d.ts +0 -134
  180. package/dist/project.d.ts +0 -395
  181. package/dist/providers.d.ts +0 -37
  182. package/dist/quota.d.ts +0 -453
  183. package/dist/recovery.d.ts +0 -621
  184. package/dist/request.d.ts +0 -1492
  185. package/dist/run.d.ts +0 -779
  186. package/dist/runtime.d.ts +0 -212
  187. package/dist/search.d.ts +0 -375
  188. package/dist/security.d.ts +0 -534
  189. package/dist/serve.d.ts +0 -136
  190. package/dist/session.d.ts +0 -296
  191. package/dist/storage.d.ts +0 -53
  192. package/dist/streaming.d.ts +0 -1119
  193. package/dist/swarm.d.ts +0 -328
  194. package/dist/tools.d.ts +0 -170
  195. package/dist/types.d.ts +0 -8357
  196. package/dist/utils.d.ts +0 -82
  197. package/dist/version.d.ts +0 -7
  198. package/dist/versionregistry.d.ts +0 -406
  199. package/dist/views.d.ts +0 -579
  200. package/dist/workbenchsession.d.ts +0 -69
  201. package/dist/workflow.d.ts +0 -649
  202. package/docs/readme.md +0 -92
  203. package/docs/schemas/antigravity.json +0 -192
  204. package/engine.ts +0 -1524
  205. package/environments.ts +0 -1005
  206. package/evidence.ts +0 -643
  207. package/export.ts +0 -889
  208. package/fingerprint.ts +0 -1458
  209. package/flow.ts +0 -1190
  210. package/gates.ts +0 -307
  211. package/gateway.ts +0 -34
  212. package/hardening.ts +0 -191
  213. package/headless.ts +0 -369
  214. package/http.ts +0 -2110
  215. package/identity.ts +0 -246
  216. package/ids.ts +0 -26
  217. package/index.ts +0 -369
  218. package/llm.ts +0 -2682
  219. package/mcp.ts +0 -1835
  220. package/models.ts +0 -3805
  221. package/modes.ts +0 -43
  222. package/net.ts +0 -1214
  223. package/neutral.ts +0 -362
  224. package/node.ts +0 -26
  225. package/oauth.ts +0 -2216
  226. package/offscreen.ts +0 -170
  227. package/pack.ts +0 -956
  228. package/page.ts +0 -7824
  229. package/pagebridge.ts +0 -1116
  230. package/perf.ts +0 -957
  231. package/plan.ts +0 -672
  232. package/plugin.ts +0 -76
  233. package/prisma.config.ts +0 -27
  234. package/progress.ts +0 -1308
  235. package/project.ts +0 -1291
  236. package/protocol.ts +0 -4187
  237. package/providers.ts +0 -246
  238. package/quota.ts +0 -1887
  239. package/recovery.ts +0 -2433
  240. package/request.ts +0 -4482
  241. package/run.ts +0 -2135
  242. package/runtime.ts +0 -526
  243. package/search.ts +0 -1760
  244. package/security.ts +0 -1673
  245. package/serve.ts +0 -419
  246. package/server.ts +0 -5538
  247. package/session.ts +0 -1162
  248. package/storage.ts +0 -182
  249. package/streaming.ts +0 -4101
  250. package/swarm.ts +0 -884
  251. package/sync.ts +0 -56
  252. package/tests/agentcert.test.ts +0 -128
  253. package/tests/agentfleet.test.ts +0 -214
  254. package/tests/agentreview.test.ts +0 -241
  255. package/tests/agentstream.test.ts +0 -335
  256. package/tests/agentwork.test.ts +0 -356
  257. package/tests/apifreeze.test.ts +0 -290
  258. package/tests/apimap.test.ts +0 -112
  259. package/tests/approvalgate.test.ts +0 -175
  260. package/tests/artifactmanifest.test.ts +0 -204
  261. package/tests/attentionfeed.test.ts +0 -163
  262. package/tests/backgroundruns.test.ts +0 -100
  263. package/tests/batchscheduling.test.ts +0 -131
  264. package/tests/blackboard.test.ts +0 -238
  265. package/tests/bridge.test.ts +0 -1088
  266. package/tests/browsercoverage.test.ts +0 -134
  267. package/tests/browserpolyfills.test.ts +0 -124
  268. package/tests/build.mjs +0 -1260
  269. package/tests/clientauth.test.ts +0 -223
  270. package/tests/clitools.test.ts +0 -578
  271. package/tests/confirmgates.test.ts +0 -190
  272. package/tests/containerpack.test.ts +0 -143
  273. package/tests/controlflow.test.ts +0 -2120
  274. package/tests/coordination.test.ts +0 -345
  275. package/tests/copyscreen.test.ts +0 -139
  276. package/tests/core.test.ts +0 -417
  277. package/tests/costcert.test.ts +0 -95
  278. package/tests/crossbrowser.test.ts +0 -115
  279. package/tests/cspaudit.test.ts +0 -79
  280. package/tests/dashdone.test.ts +0 -615
  281. package/tests/datagrid.test.ts +0 -137
  282. package/tests/doccheck.test.ts +0 -107
  283. package/tests/dombatch.test.ts +0 -109
  284. package/tests/emulation.test.ts +0 -282
  285. package/tests/environments.test.ts +0 -259
  286. package/tests/evidenceviews.test.ts +0 -88
  287. package/tests/exporttools.test.ts +0 -185
  288. package/tests/firefoxprep.test.ts +0 -134
  289. package/tests/flowlibrary.test.ts +0 -242
  290. package/tests/flowrun.test.ts +0 -303
  291. package/tests/forensics.test.ts +0 -439
  292. package/tests/gateway.test.ts +0 -939
  293. package/tests/headlesslib.test.ts +0 -171
  294. package/tests/httpstream.test.ts +0 -277
  295. package/tests/immutablelog.test.ts +0 -157
  296. package/tests/inboundguard.test.ts +0 -177
  297. package/tests/lazyload.test.ts +0 -72
  298. package/tests/librarymodes.test.ts +0 -623
  299. package/tests/llm.test.ts +0 -696
  300. package/tests/manifest.test.ts +0 -226
  301. package/tests/maskinputs.test.ts +0 -150
  302. package/tests/matrixverify.test.ts +0 -111
  303. package/tests/mavenpack.test.ts +0 -75
  304. package/tests/mcpserver.test.ts +0 -704
  305. package/tests/memory.test.ts +0 -4155
  306. package/tests/memorycare.test.ts +0 -230
  307. package/tests/migrateplan.test.ts +0 -349
  308. package/tests/minimization.test.ts +0 -414
  309. package/tests/modelroute.test.ts +0 -112
  310. package/tests/multiagent.test.ts +0 -440
  311. package/tests/nativebridge.test.ts +0 -807
  312. package/tests/navigation.test.ts +0 -284
  313. package/tests/nugetpack.test.ts +0 -83
  314. package/tests/orchestration.test.ts +0 -449
  315. package/tests/originpolicy.test.ts +0 -405
  316. package/tests/outputcompare.test.ts +0 -97
  317. package/tests/package.test.ts +0 -91
  318. package/tests/pagebridge.test.ts +0 -5909
  319. package/tests/pentest.test.ts +0 -75
  320. package/tests/perfrecords.test.ts +0 -257
  321. package/tests/permdiff.test.ts +0 -85
  322. package/tests/phishguard.test.ts +0 -128
  323. package/tests/pickerviews.test.ts +0 -155
  324. package/tests/pipelines.test.ts +0 -381
  325. package/tests/planlint.test.ts +0 -240
  326. package/tests/planreview.test.ts +0 -383
  327. package/tests/platformtargets.test.ts +0 -56
  328. package/tests/policy.test.ts +0 -8284
  329. package/tests/portability.test.ts +0 -96
  330. package/tests/progress.test.ts +0 -1036
  331. package/tests/promptlibrary.test.ts +0 -116
  332. package/tests/protocol.test.ts +0 -4793
  333. package/tests/publishingpipeline.test.ts +0 -314
  334. package/tests/quickactions.test.ts +0 -164
  335. package/tests/readiness.test.ts +0 -78
  336. package/tests/recipes.test.ts +0 -163
  337. package/tests/redactshots.test.ts +0 -216
  338. package/tests/registernvidiakeys.ts +0 -51
  339. package/tests/relayserve.test.ts +0 -304
  340. package/tests/releasenotes.test.ts +0 -276
  341. package/tests/resilience.test.ts +0 -467
  342. package/tests/resourceaware.test.ts +0 -187
  343. package/tests/runbudget.test.ts +0 -125
  344. package/tests/runhistory.test.ts +0 -299
  345. package/tests/runreplay.test.ts +0 -113
  346. package/tests/runresume.test.ts +0 -184
  347. package/tests/runstate.test.ts +0 -288
  348. package/tests/runtimeadapters.test.ts +0 -251
  349. package/tests/safariskeleton.test.ts +0 -115
  350. package/tests/sandboxframe.test.ts +0 -196
  351. package/tests/sbom.test.ts +0 -105
  352. package/tests/secretvault.test.ts +0 -188
  353. package/tests/selcache.test.ts +0 -111
  354. package/tests/sessioninterface.test.ts +0 -791
  355. package/tests/sessions.test.ts +0 -591
  356. package/tests/siteprefs.test.ts +0 -228
  357. package/tests/snapshotdelta.test.ts +0 -165
  358. package/tests/statusviews.test.ts +0 -237
  359. package/tests/stepmeter.test.ts +0 -200
  360. package/tests/streamparse.test.ts +0 -163
  361. package/tests/stylepruning.test.ts +0 -90
  362. package/tests/surfaces.test.ts +0 -282
  363. package/tests/sweep.test.ts +0 -78
  364. package/tests/syncbridge.test.ts +0 -164
  365. package/tests/taskqueue.test.ts +0 -206
  366. package/tests/toolcalls.test.ts +0 -336
  367. package/tests/toolcatalog.test.ts +0 -201
  368. package/tests/torture-agent.test.ts +0 -706
  369. package/tests/torture-auth.test.ts +0 -461
  370. package/tests/torture-commands.test.ts +0 -895
  371. package/tests/torture-data.test.ts +0 -923
  372. package/tests/torture-environments.test.ts +0 -285
  373. package/tests/torture-export.test.ts +0 -263
  374. package/tests/torture-http.test.ts +0 -1379
  375. package/tests/torture-llm.test.ts +0 -808
  376. package/tests/torture-memory.test.ts +0 -1084
  377. package/tests/torture-plan.test.ts +0 -829
  378. package/tests/torture-run.test.ts +0 -626
  379. package/tests/torture-session.test.ts +0 -928
  380. package/tests/torture-swarm.test.ts +0 -1245
  381. package/tests/torture-tools.test.ts +0 -964
  382. package/tests/torture-workflow.test.ts +0 -2354
  383. package/tests/tourviews.test.ts +0 -52
  384. package/tests/transparency.test.ts +0 -147
  385. package/tests/trigger.test.ts +0 -941
  386. package/tests/v1sunset.test.ts +0 -428
  387. package/tests/virtlist.test.ts +0 -94
  388. package/tests/vision.test.ts +0 -540
  389. package/tests/vsixpack.test.ts +0 -145
  390. package/tests/webapi.test.ts +0 -523
  391. package/tests/workflow.test.ts +0 -1785
  392. package/tests/workfloweditor.test.ts +0 -759
  393. package/tests/xpipack.test.ts +0 -128
  394. package/tools.ts +0 -734
  395. package/types.ts +0 -9908
  396. package/umd.ts +0 -9
  397. package/utils.ts +0 -249
  398. package/version.ts +0 -8
  399. package/versionregistry.ts +0 -1310
  400. package/views.ts +0 -2153
  401. package/vitest.config.ts +0 -12
  402. package/web/App.tsx +0 -78
  403. package/web/README.md +0 -47
  404. package/web/capacitor.config.ts +0 -46
  405. package/web/const.ts +0 -19
  406. package/web/control.shell.tsx +0 -66
  407. package/web/dashboardpage.ts +0 -554
  408. package/web/db.ts +0 -493
  409. package/web/design.html +0 -541
  410. package/web/gatewayview/Gateway.tsx +0 -345
  411. package/web/gatewayview/config.ts +0 -1042
  412. package/web/gatewayview/definition.ts +0 -174
  413. package/web/icons.ts +0 -15
  414. package/web/optionspage.ts +0 -691
  415. package/web/popup.ts +0 -1651
  416. package/web/schema.prisma +0 -176
  417. package/web/sidepanel.ts +0 -7941
  418. package/web/sitemanifest.json +0 -13
  419. package/web/terminal-ui.tsx +0 -622
  420. package/web/transparencypage.ts +0 -541
  421. package/web/vite.config.ts +0 -39
  422. package/web/workspace.ts +0 -23
  423. package/workbenchmemory.ts +0 -104
  424. package/workbenchsession.ts +0 -304
  425. package/workflow.ts +0 -4495
  426. /package/{dist/bun.d.ts → bun.d.ts} +0 -0
  427. /package/{dist/cli.d.ts → cli.d.ts} +0 -0
  428. /package/{dist/companion.d.ts → companion.d.ts} +0 -0
  429. /package/{dist/crossbrowser.d.ts → crossbrowser.d.ts} +0 -0
  430. /package/{dist/dashdone.d.ts → dashdone.d.ts} +0 -0
  431. /package/{dist/deno.d.ts → deno.d.ts} +0 -0
  432. /package/{dist/hardening.d.ts → hardening.d.ts} +0 -0
  433. /package/{dist/headless.d.ts → headless.d.ts} +0 -0
  434. /package/{dist/http.d.ts → http.d.ts} +0 -0
  435. /package/{dist/index.d.ts → index.d.ts} +0 -0
  436. /package/{dist/mcp.d.ts → mcp.d.ts} +0 -0
  437. /package/{dist/memory.d.ts → memory.d.ts} +0 -0
  438. /package/{dist/node.d.ts → node.d.ts} +0 -0
  439. /package/{dist/pack.d.ts → pack.d.ts} +0 -0
  440. /package/{dist/policy.d.ts → policy.d.ts} +0 -0
  441. /package/{dist/progress.d.ts → progress.d.ts} +0 -0
  442. /package/{dist/protocol.d.ts → protocol.d.ts} +0 -0
  443. /package/{dist/server.d.ts → server.d.ts} +0 -0
  444. /package/{dist/umd.d.ts → umd.d.ts} +0 -0
package/accounts.ts DELETED
@@ -1,2087 +0,0 @@
1
- /**
2
- * @fileoverview accounts.ts — the account manager core of the merged provider lineage
3
- * @module auth/accounts
4
- * @description
5
- * Production-ready multi-account management for the provider plugin.
6
- * MERGED FILE - superset of every previous duplicate:
7
- * - Multiple Google accounts with refresh token support
8
- * - Storage at ~/.config/opencode/antigravity-accounts.json (chmod 600, atomic write)
9
- * - Round-robin + sticky active account rotation
10
- * - Double-checked locking for access token refresh
11
- * - Quota/429 detection with automatic rotation
12
- * - Background refresh of tokens close to expiry
13
- * - Import from antigravity-manager (multiple legacy formats)
14
- * - Official Gemini CLI bypass headers
15
- * - Ported from the flat-file variant: legacy flat-file reader (Account[] format,
16
- * type Account, AccountsFileV3 wrapper), QuotaInfo reporting type,
17
- * QuotaExhaustedOptions, RefreshResult, ensureAccountsFile/loadAccountsSnapshot
18
- * helpers and resolveAccountsPath/resolveConfigDir/
19
- * resolveAntagravityManagerCandidates path resolvers.
20
- *
21
- * Zero external dependencies: only node:* builtins + global fetch (Node 18+).
22
- *
23
- * @author ONDA 3 - Core Modules
24
- * @license MIT
25
- * @version 3.1.0
26
- */
27
-
28
- import * as fs from "node:fs";
29
- import * as fsp from "node:fs/promises";
30
- import * as path from "node:path";
31
- import * as os from "node:os";
32
- import * as crypto from "node:crypto";
33
-
34
- // Deduplicated against the frozen owner (constants.ts) and the canonical
35
- // in-set file (project.ts): symbols whose local definitions were value-identical
36
- // are imported and re-exported below, so each has exactly one definition
37
- // (library dedupe task 2-d1).
38
- import {
39
- GEMINI_CLI_OAUTH_CLIENT_ID,
40
- GEMINI_CLI_OAUTH_CLIENT_SECRET,
41
- OAUTH_TOKEN_URL,
42
- PROJECT_FALLBACK,
43
- resolveOpenCodeBaseDir,
44
- } from "./constants.js";
45
- // v2.1.15 Phase B: the minimal Gemini CLI UA and the gl-node api-client
46
- // snapshot come from the owners (constants.js / fingerprint.js).
47
- import {
48
- GEMINI_CLI_USER_AGENT_MINIMAL as GEMINI_CLI_USER_AGENT,
49
- GEMINI_CLI_USER_AGENT as GEMINI_CLI_USER_AGENT_FULL,
50
- } from "./constants.js";
51
- import { X_GOOG_API_CLIENT_GEMINI_CLI as X_GOOG_API_CLIENT } from "./fingerprint.js";
52
- import { resolveConfigDir as configResolveConfigDir } from "./config.js";
53
- // v2.1.16 single-owner fix + the grand merge consolidation: the real
54
- // antigravity-cli client pair is imported from constants.js (the layer-0
55
- // raw-value owner — env-overridable, live-validated); the former local
56
- // segment-assembled copies were byte-identical duplicates. The owner never
57
- // rides an import cycle, so this module reads it at evaluation time in any
58
- // order (auth.ts keeps the same value through its own constants alias).
59
- import {
60
- ANTIGRAVITY_CLI_OAUTH_CLIENT_ID as AGCLI_CLIENT_ID_OWNER,
61
- ANTIGRAVITY_CLI_OAUTH_CLIENT_SECRET as AGCLI_CLIENT_SECRET_OWNER,
62
- } from "./constants.js";
63
- import {
64
- CLOUDCODE_BASE_URL,
65
- CLOUDCODE_ENDPOINTS,
66
- CLIENT_METADATA_STRING as CLIENT_METADATA,
67
- GEMINI_CLI_SCOPES,
68
- OAUTH_USERINFO_URL_V3,
69
- } from "./constants.js";
70
- import { GEMINI_CLI_SCOPES as OAUTH_SCOPES } from "./constants.js";
71
-
72
- // Re-exports preserve this module's public surface after the dedupe.
73
- export {
74
- GEMINI_CLI_OAUTH_CLIENT_ID,
75
- GEMINI_CLI_OAUTH_CLIENT_SECRET,
76
- OAUTH_TOKEN_URL,
77
- PROJECT_FALLBACK,
78
- GEMINI_CLI_USER_AGENT,
79
- GEMINI_CLI_USER_AGENT_FULL,
80
- X_GOOG_API_CLIENT,
81
- };
82
-
83
- // ============================================================================
84
- // SECTION 00. HARDENED CONSTANTS - GEMINI CLI BYPASS
85
- // ============================================================================
86
-
87
- /** Official Gemini CLI OAuth2 Client ID - raw without suffix */
88
- export const GEMINI_CLI_CLIENT_ID_RAW = "681255809395-oo8f…b135j" as const;
89
-
90
- // GEMINI_CLI_OAUTH_CLIENT_ID / GEMINI_CLI_OAUTH_CLIENT_SECRET: identical to the
91
- // constants.ts owner definitions — imported and re-exported above.
92
-
93
- /**
94
- * Antigravity CLI OAuth client (`agy` binary) — the client the provider
95
- * account console presents for NEW logins since 2.1.8. v2.1.16 single-owner
96
- * fix: imported from constants.js (the layer-0 raw-value owner —
97
- * env-overridable through ANTIGRAVITY_CLIENT_ID /
98
- * ANTIGRAVITY_CLIENT_SECRET); the former local segment-assembled copy was a
99
- * byte-identical duplicate.
100
- */
101
- export const ANTIGRAVITY_CLI_OAUTH_CLIENT_ID: string = AGCLI_CLIENT_ID_OWNER;
102
-
103
- /** Antigravity CLI client secret — alias of the constants.js owner (v2.1.16). */
104
- export const ANTIGRAVITY_CLI_OAUTH_CLIENT_SECRET: string = AGCLI_CLIENT_SECRET_OWNER;
105
-
106
- // local variant: diverges from constants (3-scope list vs constants' 8-scope GEMINI_CLI_SCOPES; the value matches constants' GEMINI_CLI_SCOPES under a different name)
107
- // v2.1.15 Phase C: GEMINI_CLI_SCOPES (3-scope Gemini CLI set) is constants.js
108
- // GEMINI_CLI_SCOPES — imported from the owner.
109
-
110
- // v2.1.15 Phase C: the v3 userinfo URL moved to constants.js as
111
- // OAUTH_USERINFO_URL_V3 (the v2 URL stays the canonical OAUTH_USERINFO_URL_V3).
112
-
113
- /** Bypass headers - canonical values required by WAVE 3.
114
- * v2.1.16 single-owner fix: alias of constants.GEMINI_CLI_USER_AGENT (the
115
- * full live-validated UA string — byte-identical to the former local copy). */
116
- // GEMINI_CLI_USER_AGENT / X_GOOG_API_CLIENT: static values identical to the
117
- // canonical project.ts definitions — imported and re-exported above
118
- // (constants.ts's same-named symbols are dynamic and therefore divergent).
119
-
120
- // local variant: diverges from constants (static GEMINI plugin metadata string vs the dynamic buildClientMetadata() value)
121
-
122
- // local variant: no same-named owner export (constants owns the individual endpoint paths and CODE_ASSIST_ENDPOINTS under other names)
123
-
124
- // PROJECT_FALLBACK: identical "rising-fact-p41fc" — imported and re-exported above.
125
-
126
- // v2.1.15 Phase A: MODELS_2026 (the 11-id legacy bypass list) and the
127
- // Model2026 union moved to models.ts (MODELS_2026_LEGACY / Model2026) —
128
- // imported under the historical name and re-exported so the public surface
129
- // is unchanged.
130
- import { MODELS_2026_LEGACY as MODELS_2026 } from "./models.js";
131
- import type { Model2026 } from "./models.js";
132
- export { MODELS_2026 };
133
- export type { Model2026 };
134
-
135
- // ============================================================================
136
- // SECTION 01. CORE TYPES
137
- // ============================================================================
138
-
139
- export interface OAuthClientConfig {
140
- clientId: string;
141
- clientSecret: string;
142
- }
143
-
144
- export type OAuthClientKey = "gemini-cli" | "antigravity-manager" | "antigravity" | "custom" | string;
145
-
146
- export interface AccountMetadataV3 {
147
- /** Normalized lower-case email */
148
- email: string;
149
- /** OAuth2 refresh token (long-lived) */
150
- refreshToken: string;
151
- /** Current access token (short-lived, 1h) */
152
- accessToken?: string | undefined;
153
- /** Epoch ms when accessToken expires */
154
- expiryDate?: number | undefined;
155
- /** Linked Cloud Project ID (or fallback) */
156
- projectId?: string | undefined;
157
- /** Whether the account is enabled for use */
158
- enabled: boolean;
159
- /** Identifying key of the OAuth client used */
160
- oauthClientKey: OAuthClientKey;
161
- /** Custom OAuth config, if oauthClientKey=custom */
162
- oauthClientConfig?: OAuthClientConfig | undefined;
163
- /** Epoch ms of last successful refresh */
164
- lastRefresh?: number | undefined;
165
- /** Epoch ms of record creation */
166
- createdAt: number;
167
- /** Epoch ms of last use (for LRU / rotation) */
168
- lastUsedAt?: number | undefined;
169
- /** Consecutive failure counter (quota, 401, etc) */
170
- failureCount?: number | undefined;
171
- /** Reason for temporary disablement */
172
- disabledReason?: string | undefined;
173
- /** Epoch ms until which the account stays disabled (cooldown) */
174
- disabledUntil?: number | undefined;
175
- /** Scopes this account holds (optional, for audit) */
176
- scopes?: string[] | undefined;
177
- }
178
-
179
- export interface AccountStore {
180
- /** Schema version - always 3 */
181
- version: 3;
182
- /** Account list */
183
- accounts: AccountMetadataV3[];
184
- /** Active email in sticky mode */
185
- activeEmail?: string | null;
186
- /** Round-robin rotation index */
187
- rotationIndex?: number;
188
- /** Selection strategy: round-robin (default) or sticky */
189
- rotationStrategy?: "round-robin" | "sticky";
190
- /** When the store was created */
191
- createdAt?: number | undefined;
192
- /** Last update */
193
- updatedAt?: number | undefined;
194
- /** Last error-driven rotation */
195
- lastRotationAt?: number | undefined;
196
- /** Email that caused the last rotation (for debug) */
197
- lastRotatedFrom?: string | undefined;
198
- }
199
-
200
- export type RotationStrategy = "round-robin" | "sticky";
201
-
202
- export interface AddAccountInput {
203
- email: string;
204
- refreshToken: string;
205
- accessToken?: string;
206
- expiryDate?: number;
207
- projectId?: string;
208
- enabled?: boolean;
209
- oauthClientKey?: OAuthClientKey;
210
- oauthClientConfig?: OAuthClientConfig;
211
- scopes?: string[];
212
- }
213
-
214
- export interface ImportResult {
215
- imported: number;
216
- skipped: number;
217
- updated: number;
218
- errors: string[];
219
- sources: string[];
220
- accounts: AccountMetadataV3[];
221
- }
222
-
223
- export interface TokenRefreshResponse {
224
- access_token: string;
225
- expires_in: number;
226
- token_type: string;
227
- scope?: string;
228
- id_token?: string;
229
- }
230
-
231
- // ============================================================================
232
- // SECTION 02. LEGACY FLAT-FILE COMPAT TYPES (ported from flat variant)
233
- // ============================================================================
234
-
235
- /**
236
- * Quota snapshot per account, mirrors v1internal:retrieveUserQuotaSummary
237
- * but kept lightweight in storage. Reporting-only type.
238
- */
239
- export interface QuotaInfo {
240
- /** Remaining percentage or tokens, optional */
241
- remaining?: number;
242
- /** Limit */
243
- limit?: number;
244
- /** Reset timestamp ms */
245
- resetAt?: number;
246
- /** True when exhausted */
247
- exhausted?: boolean;
248
- /** Raw group mapped from model to group, e.g. antigravity, gemini-cli */
249
- group?: string;
250
- /** Last check ms */
251
- lastChecked?: number;
252
- /** Per-model counters when dual pool */
253
- pools?: Record<string, { remaining?: number; resetAt?: number }>;
254
- }
255
-
256
- /**
257
- * V3 legacy Account - flat storage format compatible with the
258
- * antigravity-manager flat export {email, refresh_token}.
259
- */
260
- export interface Account {
261
- /** Unique email, lowercased */
262
- email: string;
263
- /** Google OAuth refresh token (1//...) */
264
- refreshToken: string;
265
- /** Cached bearer access token */
266
- accessToken?: string | undefined;
267
- /** Expiry epoch ms (Date.now() + expires_in*1000) */
268
- expiry?: number | undefined;
269
- /** Cloud project id resolved via loadCodeAssist / onboardUser */
270
- projectId?: string | undefined;
271
- /** Soft disabled flag - user can toggle */
272
- disabled?: boolean | undefined;
273
- /** Quota exhausted until epoch ms - skip in rotation */
274
- quotaExhaustedUntil?: number | undefined;
275
- /** Last usage epoch ms */
276
- lastUsed?: number | undefined;
277
- /** Creation epoch ms */
278
- createdAt?: number | undefined;
279
- /** Update epoch ms */
280
- updatedAt?: number | undefined;
281
- /** Cached quota snapshot */
282
- quota?: QuotaInfo | undefined;
283
- /** Optional stable id (uuid) */
284
- id?: string | undefined;
285
- /** Account pool type */
286
- type?: "antigravity" | "gemini-cli" | "auto" | string | undefined;
287
- /** Display name from userinfo */
288
- displayName?: string | undefined;
289
- /** Optional metadata - not persisted critically */
290
- metadata?: Record<string, unknown> | undefined;
291
- }
292
-
293
- /** Flat file wrapper - versioned for forward migration */
294
- export interface AccountsFileV3 {
295
- version: 3;
296
- accounts: Account[];
297
- lastRotationIndex?: number;
298
- lastStickyEmail?: string | null;
299
- updatedAt?: number;
300
- /** Optional fallback project cache */
301
- fallbackProjectId?: string;
302
- }
303
-
304
- /** Result of a token refresh in flat shape */
305
- export interface RefreshResult {
306
- accessToken: string;
307
- /** Expiry epoch ms */
308
- expiry: number;
309
- projectId?: string;
310
- raw?: Record<string, unknown>;
311
- }
312
-
313
- /** Options for marking quota exhaustion */
314
- export interface QuotaExhaustedOptions {
315
- durationMs?: number;
316
- reason?: string;
317
- group?: string;
318
- model?: string;
319
- }
320
-
321
- // ============================================================================
322
- // SECTION 03. PATH & FS HELPERS - PRODUCTION-READY
323
- // ============================================================================
324
-
325
- // resolveOpenCodeBaseDir: identical to the constants.ts owner definition — imported above.
326
-
327
- /**
328
- * Resolves the config directory honoring OPENCODE_CONFIG_DIR env fallback.
329
- * Pure resolver - no side effects here.
330
- */
331
- // v2.1.15 Phase C: resolveConfigDir moved to config.js (configuration
332
- // management owner) — re-exported below for the module surface.
333
- import { resolveConfigDir } from "./config.js";
334
- export { resolveConfigDir };
335
-
336
- export function resolveAccountsFilePath(customDir?: string): string {
337
- if (customDir) {
338
- return path.join(path.resolve(customDir), "antigravity-accounts.json");
339
- }
340
- const base = resolveOpenCodeBaseDir();
341
- return path.join(base, "antigravity-accounts.json");
342
- }
343
-
344
- /**
345
- * Resolves the full accounts file path.
346
- * When custom is provided it is treated as a complete file path override;
347
- * otherwise resolves to <configDir>/antigravity-accounts.json.
348
- */
349
- export function resolveAccountsPath(custom?: string): string {
350
- if (custom && custom.trim().length > 0) return path.resolve(custom);
351
- const dir = resolveConfigDir();
352
- return path.join(dir, "antigravity-accounts.json");
353
- }
354
-
355
- /**
356
- * Candidate paths for antigravity-manager import.
357
- * Honors ANTIGRAVITY_MANAGER_ACCOUNTS_PATH env override and Windows AppData.
358
- */
359
- export function resolveAntagravityManagerCandidates(): string[] {
360
- const home = os.homedir();
361
- const custom = process.env.ANTIGRAVITY_MANAGER_ACCOUNTS_PATH?.trim();
362
- const list: string[] = [];
363
- if (custom) list.push(path.resolve(custom));
364
- list.push(
365
- path.join(home, ".config", "antigravity-manager", "accounts.json"),
366
- path.join(home, ".config", "antigravity-manager", "credentials.json"),
367
- path.join(home, ".antigravity-manager", "accounts.json"),
368
- path.join(home, ".antigravity-manager", "credentials.json"),
369
- path.join(home, "Library", "Application Support", "antigravity-manager", "accounts.json"),
370
- path.join(home, ".config", "zerogravity", "accounts.json"),
371
- path.join(home, ".config", "opencode", "antigravity-accounts.json.bak"),
372
- );
373
- // Windows AppData
374
- if (process.env.APPDATA) {
375
- list.push(
376
- path.join(process.env.APPDATA, "antigravity-manager", "accounts.json"),
377
- path.join(process.env.APPDATA, "antigravity-manager", "credentials.json"),
378
- );
379
- }
380
- return list;
381
- }
382
-
383
- function ensureDirExists(dir: string): void {
384
- try {
385
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
386
- } catch {
387
- // best effort - ignore if it already exists
388
- }
389
- // Guarantee permission 0700 even if it already existed
390
- try {
391
- fs.chmodSync(dir, 0o700);
392
- } catch {
393
- // may fail on some filesystems / containers
394
- }
395
- }
396
-
397
- /**
398
- * Ensures dir exists asynchronously, tolerant to races.
399
- */
400
- async function ensureDirExistsAsync(dir: string): Promise<void> {
401
- try {
402
- await fsp.mkdir(dir, { recursive: true });
403
- } catch {
404
- // best effort chmod even if it exists
405
- try {
406
- await fsp.chmod(dir, 0o755);
407
- } catch {
408
- // ignore
409
- }
410
- }
411
- }
412
-
413
- /**
414
- * Atomic write with chmod 600
415
- * - Writes to a temp file in the same directory
416
- * - chmod 600
417
- * - Atomic rename
418
- */
419
- function atomicWriteFileJson(filePath: string, data: string): void {
420
- const dir = path.dirname(filePath);
421
- ensureDirExists(dir);
422
-
423
- const tmpSuffix = crypto.randomBytes(6).toString("hex");
424
- const tmpPath = `${filePath}.tmp.${process.pid}.${tmpSuffix}`;
425
-
426
- try {
427
- // Write temp with restrictive mode
428
- fs.writeFileSync(tmpPath, data, { encoding: "utf8", mode: 0o600 });
429
- try {
430
- fs.chmodSync(tmpPath, 0o600);
431
- } catch {
432
- // ignore
433
- }
434
- // Atomic rename (same filesystem)
435
- fs.renameSync(tmpPath, filePath);
436
- try {
437
- fs.chmodSync(filePath, 0o600);
438
- } catch {
439
- // ignore
440
- }
441
- } finally {
442
- // Best-effort cleanup of temp if it failed before rename
443
- try {
444
- if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
445
- } catch {
446
- // ignore
447
- }
448
- }
449
- }
450
-
451
- /**
452
- * Async atomic write: temp + rename + chmod 600.
453
- * Observer-safe - no partial file exposure.
454
- */
455
- async function atomicWriteFileJsonAsync(targetPath: string, data: string): Promise<void> {
456
- const dir = path.dirname(targetPath);
457
- await ensureDirExistsAsync(dir);
458
-
459
- const rand = crypto.randomBytes(6).toString("hex");
460
- const tmpName = `.antigravity-accounts.tmp-${process.pid}-${rand}.json`;
461
- const tmpPath = path.join(dir, tmpName);
462
-
463
- // Write temp
464
- await fsp.writeFile(tmpPath, data, { encoding: "utf8", mode: 0o600 });
465
- try {
466
- await fsp.chmod(tmpPath, 0o600);
467
- } catch {
468
- // ignore on windows
469
- }
470
- // Atomic rename
471
- await fsp.rename(tmpPath, targetPath);
472
- try {
473
- await fsp.chmod(targetPath, 0o600);
474
- } catch {
475
- // windows may not support chmod 600
476
- }
477
- }
478
-
479
- function normalizeEmail(email: string): string {
480
- return email.trim().toLowerCase();
481
- }
482
-
483
- function isValidEmail(email: string): boolean {
484
- // Linear-time validation (regex-free; the former /^[^\s@]+@[^\s@]+\.[^\s@]+$/
485
- // had nested quantifiers and was flagged as ReDoS-prone on uncontrolled input).
486
- if (email.length === 0 || /\s/.test(email)) return false;
487
- const at = email.indexOf("@");
488
- if (at <= 0 || email.indexOf("@", at + 1) !== -1) return false;
489
- const domain = email.slice(at + 1);
490
- const dot = domain.lastIndexOf(".");
491
- return dot > 0 && dot < domain.length - 1;
492
- }
493
-
494
- function nowMs(): number {
495
- return Date.now();
496
- }
497
-
498
- function buildDefaultStore(): AccountStore {
499
- const t = nowMs();
500
- return {
501
- version: 3,
502
- accounts: [],
503
- activeEmail: null,
504
- rotationIndex: 0,
505
- rotationStrategy: "round-robin",
506
- createdAt: t,
507
- updatedAt: t,
508
- };
509
- }
510
-
511
- function sanitizeStore(raw: unknown): AccountStore {
512
- const def = buildDefaultStore();
513
-
514
- if (!raw || typeof raw !== "object") return def;
515
-
516
- const obj = raw as Record<string, unknown>;
517
-
518
- // Supports old versions without version field or with version 1/2
519
- const accountsRaw = Array.isArray(obj.accounts) ? obj.accounts : [];
520
- const accounts: AccountMetadataV3[] = [];
521
-
522
- for (const a of accountsRaw) {
523
- const normalized = normalizeAccountRaw(a);
524
- if (normalized) accounts.push(normalized);
525
- }
526
-
527
- // Also supports legacy format where root is an array
528
- if (accounts.length === 0 && Array.isArray(raw)) {
529
- for (const a of raw as unknown[]) {
530
- const n = normalizeAccountRaw(a);
531
- if (n) accounts.push(n);
532
- }
533
- }
534
-
535
- const activeEmail = typeof obj.activeEmail === "string" ? normalizeEmail(obj.activeEmail) : null;
536
-
537
- const rotIdx =
538
- typeof obj.rotationIndex === "number" && Number.isFinite(obj.rotationIndex)
539
- ? Math.max(0, Math.floor(obj.rotationIndex))
540
- : 0;
541
-
542
- const rotationStrategy =
543
- obj.rotationStrategy === "sticky" || obj.rotationStrategy === "round-robin" ? obj.rotationStrategy : "round-robin";
544
-
545
- return {
546
- version: 3,
547
- accounts,
548
- activeEmail,
549
- rotationIndex: rotIdx % Math.max(1, accounts.length || 1),
550
- rotationStrategy,
551
- createdAt: typeof obj.createdAt === "number" ? obj.createdAt : def.createdAt,
552
- updatedAt: nowMs(),
553
- lastRotationAt: typeof obj.lastRotationAt === "number" ? obj.lastRotationAt : undefined,
554
- lastRotatedFrom: typeof obj.lastRotatedFrom === "string" ? obj.lastRotatedFrom : undefined,
555
- };
556
- }
557
-
558
- function normalizeAccountRaw(raw: unknown): AccountMetadataV3 | null {
559
- if (!raw || typeof raw !== "object") return null;
560
- const r = raw as Record<string, unknown>;
561
-
562
- const emailRaw =
563
- (r.email as string) ?? (r.user_email as string) ?? (r.userEmail as string) ?? (r.account as string) ?? "";
564
-
565
- if (typeof emailRaw !== "string" || !isValidEmail(emailRaw)) return null;
566
- const email = normalizeEmail(emailRaw);
567
-
568
- const refreshTokenRaw =
569
- (r.refreshToken as string) ??
570
- (r.refresh_token as string) ??
571
- (r.refresh as string) ??
572
- (r.token as any)?.refresh_token ??
573
- (r.tokens as any)?.refresh_token ??
574
- "";
575
-
576
- if (typeof refreshTokenRaw !== "string" || refreshTokenRaw.length < 10) return null;
577
-
578
- const accessToken =
579
- typeof r.accessToken === "string"
580
- ? (r.accessToken as string)
581
- : typeof r.access_token === "string"
582
- ? (r.access_token as string)
583
- : ((r.token as any)?.access_token ?? undefined);
584
-
585
- let expiryDate: number | undefined;
586
- if (typeof r.expiryDate === "number") expiryDate = r.expiryDate;
587
- else if (typeof r.expiry_date === "number") expiryDate = r.expiry_date;
588
- else if (typeof r.expiryDate === "string") {
589
- const n = Number(r.expiryDate);
590
- if (Number.isFinite(n)) expiryDate = n;
591
- } else if (typeof r.expires_at === "number") {
592
- // May be seconds or ms - heuristic
593
- expiryDate = r.expires_at > 1e12 ? r.expires_at : r.expires_at * 1000;
594
- } else if ((r.token as any)?.expiry_date) {
595
- expiryDate = (r.token as any).expiry_date;
596
- } else if (typeof r.expires_in === "number") {
597
- // If expires_in exists but no expiry, compute from lastRefresh or now
598
- const base = typeof r.lastRefresh === "number" ? r.lastRefresh : nowMs();
599
- expiryDate = base + r.expires_in * 1000;
600
- }
601
-
602
- const projectId =
603
- (r.projectId as string) ??
604
- (r.project_id as string) ??
605
- (r.cloudaicompanionProject as string) ??
606
- (r.project as string) ??
607
- PROJECT_FALLBACK;
608
-
609
- const enabled = typeof r.enabled === "boolean" ? r.enabled : true;
610
-
611
- const oauthClientKey: OAuthClientKey =
612
- typeof r.oauthClientKey === "string"
613
- ? (r.oauthClientKey as string)
614
- : typeof r.oauth_client_key === "string"
615
- ? (r.oauth_client_key as string)
616
- : "gemini-cli";
617
-
618
- let oauthClientConfig: OAuthClientConfig | undefined;
619
- if (r.oauthClientConfig && typeof r.oauthClientConfig === "object") {
620
- const cfg = r.oauthClientConfig as Record<string, unknown>;
621
- if (typeof cfg.clientId === "string" && typeof cfg.clientSecret === "string") {
622
- oauthClientConfig = {
623
- clientId: cfg.clientId,
624
- clientSecret: cfg.clientSecret,
625
- };
626
- }
627
- }
628
-
629
- const lastRefresh =
630
- typeof r.lastRefresh === "number" ? r.lastRefresh : typeof r.last_refresh === "number" ? r.last_refresh : undefined;
631
-
632
- const createdAt =
633
- typeof r.createdAt === "number" ? r.createdAt : typeof r.created_at === "number" ? r.created_at : nowMs();
634
-
635
- const lastUsedAt = typeof r.lastUsedAt === "number" ? r.lastUsedAt : undefined;
636
-
637
- const failureCount = typeof r.failureCount === "number" ? Math.max(0, r.failureCount) : 0;
638
-
639
- const disabledReason = typeof r.disabledReason === "string" ? r.disabledReason : undefined;
640
-
641
- const disabledUntil = typeof r.disabledUntil === "number" ? r.disabledUntil : undefined;
642
-
643
- const scopes = Array.isArray(r.scopes) ? (r.scopes as string[]).filter((s) => typeof s === "string") : undefined;
644
-
645
- return {
646
- email,
647
- refreshToken: refreshTokenRaw,
648
- accessToken: typeof accessToken === "string" ? accessToken : undefined,
649
- expiryDate,
650
- projectId: typeof projectId === "string" ? projectId : PROJECT_FALLBACK,
651
- enabled,
652
- oauthClientKey,
653
- oauthClientConfig,
654
- lastRefresh,
655
- createdAt,
656
- lastUsedAt,
657
- failureCount,
658
- disabledReason,
659
- disabledUntil,
660
- scopes,
661
- };
662
- }
663
-
664
- // ============================================================================
665
- // SECTION 04. LEGACY FLAT-FILE READER (Account[] format)
666
- // Parses legacy file shapes into normalized Account arrays.
667
- // ============================================================================
668
-
669
- /** Legacy shapes tolerated by the flat-file reader */
670
- type LegacyFlatFile =
671
- | AccountsFileV3
672
- | { accounts: Account[] }
673
- | { accounts: Record<string, Account> }
674
- | Account[]
675
- | { version?: number; [k: string]: unknown };
676
-
677
- /**
678
- * Sanitizes a flat-format account for persistence -
679
- * strips undefined, normalizes email.
680
- */
681
- function sanitizeLegacyAccount(acc: Account): Account {
682
- const now = Date.now();
683
- const email = acc.email.toLowerCase().trim();
684
- return {
685
- email,
686
- refreshToken: acc.refreshToken.trim(),
687
- accessToken: acc.accessToken?.trim() || undefined,
688
- expiry: acc.expiry,
689
- projectId: acc.projectId?.trim() || PROJECT_FALLBACK,
690
- disabled: !!acc.disabled,
691
- quotaExhaustedUntil: acc.quotaExhaustedUntil,
692
- lastUsed: acc.lastUsed,
693
- createdAt: acc.createdAt ?? now,
694
- updatedAt: now,
695
- quota: acc.quota,
696
- id: acc.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
697
- type: acc.type ?? "antigravity",
698
- displayName: acc.displayName,
699
- metadata: acc.metadata,
700
- };
701
- }
702
-
703
- /**
704
- * Legacy flat-file reader.
705
- * Tolerates: flat array (antigravity-manager export), AccountsFileV3 wrapper,
706
- * {accounts: [...]} array, {accounts: {email: {...}}} map and
707
- * single-account object forms. Returns normalized Account[] plus
708
- * rotation metadata when present.
709
- */
710
- function parseLegacyAccounts(raw: unknown): { accounts: Account[]; rotationIndex: number; stickyEmail: string | null } {
711
- const now = Date.now();
712
- let accounts: Account[] = [];
713
- let rotationIndex = 0;
714
- let stickyEmail: string | null = null;
715
-
716
- if (!raw) return { accounts, rotationIndex, stickyEmail };
717
-
718
- // Flat array - antigravity-manager export
719
- if (Array.isArray(raw)) {
720
- accounts = raw
721
- .map((item: any) => {
722
- if (!item) return null;
723
- const email = (item.email ?? item.email_address ?? item.id ?? "").toString().toLowerCase().trim();
724
- const rt = (item.refresh_token ?? item.refreshToken ?? item.refreshTokenEncrypted ?? "").toString().trim();
725
- if (!email || !rt) return null;
726
- const acc: Account = {
727
- email,
728
- refreshToken: rt,
729
- accessToken: item.access_token ?? item.accessToken,
730
- expiry: item.expiry ?? item.expires_at ?? undefined,
731
- projectId: item.projectId ?? item.project_id ?? PROJECT_FALLBACK,
732
- disabled: !!item.disabled,
733
- createdAt: item.createdAt ?? now,
734
- updatedAt: now,
735
- id: item.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
736
- type: item.type ?? "antigravity",
737
- };
738
- // Normalize expiry if seconds
739
- if (acc.expiry && acc.expiry < 1e12) {
740
- // seconds -> ms if it looks like seconds
741
- if (acc.expiry > 1e9 && acc.expiry < 1e12) acc.expiry = acc.expiry * 1000;
742
- }
743
- return acc;
744
- })
745
- .filter(Boolean) as Account[];
746
- return { accounts, rotationIndex, stickyEmail };
747
- }
748
-
749
- if (typeof raw === "object") {
750
- const obj = raw as any;
751
- // AccountsFileV3 / {accounts: [...]}
752
- if (Array.isArray(obj.accounts)) {
753
- accounts = obj.accounts
754
- .map((a: any) => {
755
- if (!a?.email || (!a?.refreshToken && !a?.refresh_token)) return null;
756
- const email = a.email.toString().toLowerCase().trim();
757
- const rt = (a.refreshToken ?? a.refresh_token ?? "").toString().trim();
758
- if (!email || !rt) return null;
759
- return {
760
- ...a,
761
- email,
762
- refreshToken: rt,
763
- id: a.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
764
- createdAt: a.createdAt ?? now,
765
- updatedAt: a.updatedAt ?? now,
766
- } as Account;
767
- })
768
- .filter(Boolean) as Account[];
769
- rotationIndex = Number.isFinite(obj.lastRotationIndex) ? obj.lastRotationIndex : 0;
770
- stickyEmail = obj.lastStickyEmail ?? null;
771
- return { accounts, rotationIndex, stickyEmail };
772
- }
773
- // Map form {accounts: {email: {...}}}
774
- if (obj.accounts && typeof obj.accounts === "object" && !Array.isArray(obj.accounts)) {
775
- const map = obj.accounts as Record<string, any>;
776
- accounts = Object.entries(map)
777
- .map(([emailKey, val]: [string, any]) => {
778
- const email = (val?.email ?? emailKey).toString().toLowerCase().trim();
779
- const rt = (val?.refreshToken ?? val?.refresh_token ?? "").toString().trim();
780
- if (!email || !rt) return null;
781
- return {
782
- email,
783
- refreshToken: rt,
784
- accessToken: val.accessToken ?? val.access_token,
785
- expiry: val.expiry,
786
- projectId: val.projectId ?? PROJECT_FALLBACK,
787
- disabled: !!val.disabled,
788
- createdAt: val.createdAt ?? now,
789
- updatedAt: now,
790
- id: val.id ?? crypto.randomUUID?.() ?? `${email}-${now}`,
791
- type: val.type ?? "antigravity",
792
- } as Account;
793
- })
794
- .filter(Boolean) as Account[];
795
- return { accounts, rotationIndex, stickyEmail };
796
- }
797
- // Single account object?
798
- if (obj.email && (obj.refreshToken || obj.refresh_token)) {
799
- const email = obj.email.toString().toLowerCase().trim();
800
- const rt = (obj.refreshToken ?? obj.refresh_token).toString().trim();
801
- if (email && rt) {
802
- accounts = [
803
- {
804
- email,
805
- refreshToken: rt,
806
- accessToken: obj.accessToken,
807
- expiry: obj.expiry,
808
- projectId: obj.projectId,
809
- createdAt: now,
810
- updatedAt: now,
811
- id: crypto.randomUUID?.() ?? `${email}-${now}`,
812
- },
813
- ];
814
- }
815
- }
816
- }
817
-
818
- return { accounts, rotationIndex, stickyEmail };
819
- }
820
-
821
- /**
822
- * Helper to ensure the accounts file exists with safe perms, idempotent.
823
- * Creates an empty AccountsFileV3 payload when missing.
824
- *
825
- * @param filePath optional full file path override
826
- * @returns resolved file path
827
- */
828
- export async function ensureAccountsFile(filePath?: string): Promise<string> {
829
- const fp = resolveAccountsPath(filePath);
830
- await ensureDirExistsAsync(path.dirname(fp));
831
- try {
832
- await fsp.access(fp, fs.constants.F_OK);
833
- try {
834
- await fsp.chmod(fp, 0o600);
835
- } catch {
836
- /* the guarded best-effort operation falls through: the outer flow owns the failure */
837
- }
838
- } catch {
839
- // Create empty v3 file
840
- const empty: AccountsFileV3 = {
841
- version: 3,
842
- accounts: [],
843
- lastRotationIndex: 0,
844
- lastStickyEmail: null,
845
- updatedAt: Date.now(),
846
- fallbackProjectId: PROJECT_FALLBACK,
847
- };
848
- await atomicWriteFileJsonAsync(fp, JSON.stringify(empty, null, 2));
849
- }
850
- return fp;
851
- }
852
-
853
- /**
854
- * Quick utility - reads accounts without a manager instance.
855
- * Uses the legacy flat-file reader, tolerant to every historical shape.
856
- *
857
- * @param filePath optional full file path override
858
- * @returns sanitized Account[] snapshot (empty on missing/corrupt file)
859
- */
860
- export async function loadAccountsSnapshot(filePath?: string): Promise<Account[]> {
861
- const fp = resolveAccountsPath(filePath);
862
- try {
863
- const content = await fsp.readFile(fp, "utf8");
864
- const { accounts } = parseLegacyAccounts(JSON.parse(content));
865
- return accounts.map(sanitizeLegacyAccount);
866
- } catch {
867
- return [];
868
- }
869
- }
870
-
871
- // ============================================================================
872
- // SECTION 05. OAUTH REFRESH - BARE FETCH WITH BYPASS HEADERS
873
- // ============================================================================
874
-
875
- const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 min
876
- const BACKGROUND_REFRESH_BUFFER_MS = 10 * 60 * 1000; // 10 min
877
-
878
- function buildTokenRefreshHeaders(): Record<string, string> {
879
- return {
880
- "Content-Type": "application/x-www-form-urlencoded",
881
- "User-Agent": GEMINI_CLI_USER_AGENT_FULL,
882
- "X-Goog-Api-Client": X_GOOG_API_CLIENT,
883
- "Client-Metadata": CLIENT_METADATA,
884
- Accept: "application/json",
885
- };
886
- }
887
-
888
- function resolveOAuthClientForAccount(acct: AccountMetadataV3): OAuthClientConfig {
889
- if (acct.oauthClientConfig?.clientId && acct.oauthClientConfig?.clientSecret) {
890
- return acct.oauthClientConfig;
891
- }
892
- // Antigravity CLI client (default for logins since 2.1.8)
893
- if (acct.oauthClientKey === "antigravity-cli") {
894
- return {
895
- clientId: ANTIGRAVITY_CLI_OAUTH_CLIENT_ID,
896
- clientSecret: ANTIGRAVITY_CLI_OAUTH_CLIENT_SECRET,
897
- };
898
- }
899
- // Hardened default Gemini CLI
900
- return {
901
- clientId: GEMINI_CLI_OAUTH_CLIENT_ID,
902
- clientSecret: GEMINI_CLI_OAUTH_CLIENT_SECRET,
903
- };
904
- }
905
-
906
- async function performTokenRefresh(acct: AccountMetadataV3): Promise<TokenRefreshResponse> {
907
- const client = resolveOAuthClientForAccount(acct);
908
-
909
- const body = new URLSearchParams({
910
- client_id: client.clientId,
911
- client_secret: client.clientSecret,
912
- refresh_token: acct.refreshToken,
913
- grant_type: "refresh_token",
914
- });
915
-
916
- const headers = buildTokenRefreshHeaders();
917
-
918
- const controller = new AbortController();
919
- const timeout = setTimeout(() => controller.abort(), 15_000);
920
-
921
- try {
922
- const res = await fetch(OAUTH_TOKEN_URL, {
923
- method: "POST",
924
- headers,
925
- body: body.toString(),
926
- signal: controller.signal,
927
- });
928
-
929
- const text = await res.text();
930
- let json: any;
931
- try {
932
- json = JSON.parse(text);
933
- } catch {
934
- json = { raw: text };
935
- }
936
-
937
- if (!res.ok) {
938
- const err: any = new Error(
939
- `Token refresh failed for ${acct.email}: ${res.status} ${json.error_description || json.error || text.slice(0, 500)}`,
940
- );
941
- err.status = res.status;
942
- err.code = json.error;
943
- err.response = json;
944
- err.email = acct.email;
945
- throw err;
946
- }
947
-
948
- if (!json.access_token || typeof json.expires_in !== "number") {
949
- throw new Error(`Invalid token response for ${acct.email}: missing access_token/expires_in`);
950
- }
951
-
952
- return json as TokenRefreshResponse;
953
- } finally {
954
- clearTimeout(timeout);
955
- }
956
- }
957
-
958
- function isTokenExpired(acct: AccountMetadataV3, bufferMs = REFRESH_BUFFER_MS): boolean {
959
- if (!acct.accessToken) return true;
960
- if (!acct.expiryDate) return true;
961
- return acct.expiryDate <= nowMs() + bufferMs;
962
- }
963
-
964
- // ============================================================================
965
- // SECTION 06. QUOTA / 429 DETECTION
966
- // ============================================================================
967
-
968
- const QUOTA_ERROR_PATTERNS = [
969
- /quota/i,
970
- /rate limit/i,
971
- /rate_limit/i,
972
- /too many requests/i,
973
- /429/,
974
- /resource_exhausted/i,
975
- /RESOURCE_EXHAUSTED/,
976
- /quota_exceeded/i,
977
- /QUOTA_EXCEEDED/,
978
- /user_rate_limit/i,
979
- /Request throttled/i,
980
- /billing/i, // sometimes quota comes with billing
981
- ];
982
-
983
- export function isQuotaError(err: unknown): boolean {
984
- if (!err) return false;
985
-
986
- // Direct number
987
- if (typeof err === "number") return err === 429;
988
-
989
- const asAny = err as any;
990
-
991
- // status / code fields
992
- const status =
993
- asAny.status ??
994
- asAny.statusCode ??
995
- asAny.code ??
996
- (asAny.response && (asAny.response.status || asAny.response.statusCode));
997
-
998
- if (status === 429) return true;
999
- if (status === 403 || status === "403") {
1000
- // 403 may be quota, check message
1001
- const msg = (
1002
- asAny.message ||
1003
- asAny.error_description ||
1004
- asAny.error ||
1005
- JSON.stringify(asAny.response || asAny) ||
1006
- ""
1007
- ).toString();
1008
- if (QUOTA_ERROR_PATTERNS.some((re) => re.test(msg))) return true;
1009
- // For generic 403 on Code Assist we treat it as quota only when endpoint is generate*
1010
- // Conservative: only if it mentions quota
1011
- return false;
1012
- }
1013
-
1014
- const message = (
1015
- asAny.message ||
1016
- asAny.error ||
1017
- asAny.error_description ||
1018
- (typeof err === "string" ? err : "") ||
1019
- JSON.stringify(asAny).slice(0, 2000)
1020
- )
1021
- .toString()
1022
- .toLowerCase();
1023
-
1024
- return QUOTA_ERROR_PATTERNS.some((re) => re.test(message));
1025
- }
1026
-
1027
- function parseErrorStatus(err: unknown): number | undefined {
1028
- if (!err) return undefined;
1029
- const a = err as any;
1030
- return (
1031
- a.status ?? a.statusCode ?? a.code ?? (a.response && (a.response.status || a.response.statusCode)) ?? undefined
1032
- );
1033
- }
1034
-
1035
- // ============================================================================
1036
- // SECTION 07. AccountManager CLASS
1037
- // ============================================================================
1038
-
1039
- export interface AccountManagerOptions {
1040
- /** Override of the base directory (default ~/.config/opencode) */
1041
- configDir?: string;
1042
- /** File name (default antigravity-accounts.json) */
1043
- fileName?: string;
1044
- /** Default strategy if store does not have one */
1045
- rotationStrategy?: RotationStrategy;
1046
- /** Custom full path (takes precedence over configDir+fileName) */
1047
- filePath?: string;
1048
- /** Optional logger */
1049
- logger?: {
1050
- info?: (msg: string, ...args: any[]) => void;
1051
- warn?: (msg: string, ...args: any[]) => void;
1052
- error?: (msg: string, ...args: any[]) => void;
1053
- debug?: (msg: string, ...args: any[]) => void;
1054
- };
1055
- /** Disables background auto-refresh timer */
1056
- disableBackgroundTimer?: boolean;
1057
- }
1058
-
1059
- export class AccountManager {
1060
- private readonly storePath: string;
1061
- private store: AccountStore;
1062
- private rotationIdx: number;
1063
- private readonly refreshLocks = new Map<string, Promise<AccountMetadataV3>>();
1064
- private backgroundTimer?: NodeJS.Timeout | undefined;
1065
- private readonly logger: AccountManagerOptions["logger"];
1066
- private readonly options: AccountManagerOptions;
1067
-
1068
- constructor(opts: AccountManagerOptions = {}) {
1069
- this.options = opts;
1070
- this.logger = opts.logger ?? {};
1071
-
1072
- if (opts.filePath) {
1073
- this.storePath = path.resolve(opts.filePath);
1074
- } else {
1075
- const baseDir = opts.configDir ? path.resolve(opts.configDir) : resolveOpenCodeBaseDir();
1076
- const fileName = opts.fileName ?? "antigravity-accounts.json";
1077
- this.storePath = path.join(baseDir, fileName);
1078
- }
1079
-
1080
- this.store = this.loadAccounts();
1081
- this.rotationIdx = this.store.rotationIndex ?? 0;
1082
-
1083
- // Auto background timer if enabled (every 5 min)
1084
- if (!opts.disableBackgroundTimer) {
1085
- // unref so it does not block exit
1086
- this.backgroundTimer = setInterval(
1087
- () => {
1088
- this.backgroundRefresh().catch((e) => {
1089
- this.logger?.warn?.(`[accounts] backgroundRefresh failed: ${(e as Error).message}`);
1090
- });
1091
- },
1092
- 5 * 60 * 1000,
1093
- );
1094
- if (this.backgroundTimer && typeof (this.backgroundTimer as any).unref === "function") {
1095
- (this.backgroundTimer as any).unref();
1096
- }
1097
- }
1098
- }
1099
-
1100
- // --------------------------------------------------------------------------
1101
- // STORAGE - loadAccounts / saveAccounts (atomic chmod 600)
1102
- // --------------------------------------------------------------------------
1103
-
1104
- /**
1105
- * Loads accounts from ~/.config/opencode/antigravity-accounts.json
1106
- * - Creates the file if missing with default store
1107
- * - chmod 600 on every read/write
1108
- * - Sanitizes schema V3
1109
- */
1110
- loadAccounts(): AccountStore {
1111
- let rawContent: string | null = null;
1112
-
1113
- try {
1114
- // Ensure dir exists before trying to read
1115
- ensureDirExists(path.dirname(this.storePath));
1116
-
1117
- if (!fs.existsSync(this.storePath)) {
1118
- const def = buildDefaultStore();
1119
- if (this.options.rotationStrategy) {
1120
- def.rotationStrategy = this.options.rotationStrategy;
1121
- }
1122
- this.store = def;
1123
- this.saveAccounts(); // creates initial file with 600
1124
- this.logger?.info?.(`[accounts] created new store at ${this.storePath}`);
1125
- return { ...def };
1126
- }
1127
-
1128
- // chmod 600 even on read (hardening)
1129
- try {
1130
- fs.chmodSync(this.storePath, 0o600);
1131
- } catch {
1132
- // ignore
1133
- }
1134
-
1135
- rawContent = fs.readFileSync(this.storePath, { encoding: "utf8" });
1136
- } catch (err) {
1137
- this.logger?.warn?.(
1138
- `[accounts] failed to read store at ${this.storePath}: ${(err as Error).message}, using default`,
1139
- );
1140
- const def = buildDefaultStore();
1141
- this.store = def;
1142
- return { ...def };
1143
- }
1144
-
1145
- try {
1146
- const parsed = rawContent ? JSON.parse(rawContent) : null;
1147
- const sanitized = sanitizeStore(parsed);
1148
- if (this.options.rotationStrategy && !parsed?.rotationStrategy) {
1149
- sanitized.rotationStrategy = this.options.rotationStrategy;
1150
- }
1151
- // Normalize rotation index against current size
1152
- const len = Math.max(1, sanitized.accounts.length);
1153
- sanitized.rotationIndex = (sanitized.rotationIndex ?? 0) % len;
1154
-
1155
- this.store = sanitized;
1156
- this.rotationIdx = sanitized.rotationIndex ?? 0;
1157
- return { ...sanitized, accounts: [...sanitized.accounts] };
1158
- } catch (err) {
1159
- this.logger?.warn?.(`[accounts] invalid JSON at ${this.storePath}: ${(err as Error).message}, returning default`);
1160
- const def = buildDefaultStore();
1161
- this.store = def;
1162
- return { ...def };
1163
- }
1164
- }
1165
-
1166
- /**
1167
- * Saves store to disk atomically with chmod 600
1168
- */
1169
- saveAccounts(): void {
1170
- const toSave: AccountStore = {
1171
- ...this.store,
1172
- version: 3,
1173
- rotationIndex: this.rotationIdx,
1174
- updatedAt: nowMs(),
1175
- };
1176
-
1177
- // Sanitize again before saving (removes undefined extras)
1178
- const cleanAccounts = toSave.accounts.map((a) => {
1179
- // Remove undefined fields for clean JSON
1180
- const copy: Record<string, unknown> = { ...a };
1181
- Object.keys(copy).forEach((k) => {
1182
- if (copy[k] === undefined) delete copy[k];
1183
- });
1184
- return copy as unknown as AccountMetadataV3;
1185
- });
1186
-
1187
- const payload: AccountStore = {
1188
- ...toSave,
1189
- accounts: cleanAccounts,
1190
- };
1191
-
1192
- const json = JSON.stringify(payload, null, 2) + "\n";
1193
-
1194
- try {
1195
- atomicWriteFileJson(this.storePath, json);
1196
- this.logger?.debug?.(`[accounts] saved ${cleanAccounts.length} accounts to ${this.storePath}`);
1197
- } catch (err) {
1198
- this.logger?.error?.(`[accounts] failed to save store: ${(err as Error).message}`);
1199
- throw err;
1200
- }
1201
- }
1202
-
1203
- /**
1204
- * Saves a custom store atomically - used for tests / import
1205
- */
1206
- saveAccountsAtomic(store: AccountStore): void {
1207
- this.store = sanitizeStore(store);
1208
- this.rotationIdx = this.store.rotationIndex ?? 0;
1209
- this.saveAccounts();
1210
- }
1211
-
1212
- // --------------------------------------------------------------------------
1213
- // QUERY - counters and listings
1214
- // --------------------------------------------------------------------------
1215
-
1216
- getAccountCount(): number {
1217
- return this.store.accounts.length;
1218
- }
1219
-
1220
- getEnabledAccounts(): AccountMetadataV3[] {
1221
- const t = nowMs();
1222
- return this.store.accounts.filter((a) => {
1223
- if (!a.enabled) return false;
1224
- // Respect disabledUntil cooldown
1225
- if (a.disabledUntil && a.disabledUntil > t) return false;
1226
- // If disabledUntil expired but still enabled=false for a temporary reason, lazily re-enable?
1227
- // Do not auto re-enable here, only filter; expiry logic is handled in backgroundRefresh
1228
- return true;
1229
- });
1230
- }
1231
-
1232
- listAccounts(): AccountMetadataV3[] {
1233
- return [...this.store.accounts];
1234
- }
1235
-
1236
- /** List with refreshToken masked (safe for log/UI) */
1237
- listAccountsMasked(): Array<Omit<AccountMetadataV3, "refreshToken"> & { refreshTokenMasked: string }> {
1238
- return this.store.accounts.map((a) => {
1239
- const masked = a.refreshToken.length > 12 ? `${a.refreshToken.slice(0, 6)}...${a.refreshToken.slice(-4)}` : "***";
1240
- const { refreshToken: _rt, ...rest } = a;
1241
- return {
1242
- ...rest,
1243
- refreshTokenMasked: masked,
1244
- };
1245
- });
1246
- }
1247
-
1248
- findAccount(email: string): AccountMetadataV3 | undefined {
1249
- const norm = normalizeEmail(email);
1250
- return this.store.accounts.find((a) => normalizeEmail(a.email) === norm);
1251
- }
1252
-
1253
- private findAccountIndex(email: string): number {
1254
- const norm = normalizeEmail(email);
1255
- return this.store.accounts.findIndex((a) => normalizeEmail(a.email) === norm);
1256
- }
1257
-
1258
- // --------------------------------------------------------------------------
1259
- // MUTATIONS - add / remove / enable / disable / setActive
1260
- // --------------------------------------------------------------------------
1261
-
1262
- /**
1263
- * Adds a new account. If email already exists, updates refreshToken and other data.
1264
- * Returns the added/updated account.
1265
- */
1266
- addAccount(input: AddAccountInput): AccountMetadataV3 {
1267
- if (!input.email || !isValidEmail(input.email)) {
1268
- throw new Error(`addAccount: invalid email ${input.email}`);
1269
- }
1270
- if (!input.refreshToken || input.refreshToken.length < 10) {
1271
- throw new Error(`addAccount: invalid refreshToken for ${input.email}`);
1272
- }
1273
-
1274
- const email = normalizeEmail(input.email);
1275
- const now = nowMs();
1276
-
1277
- const existingIdx = this.findAccountIndex(email);
1278
-
1279
- const base: AccountMetadataV3 = {
1280
- email,
1281
- refreshToken: input.refreshToken,
1282
- accessToken: input.accessToken,
1283
- expiryDate: input.expiryDate,
1284
- projectId: input.projectId ?? PROJECT_FALLBACK,
1285
- enabled: input.enabled ?? true,
1286
- oauthClientKey: input.oauthClientKey ?? "gemini-cli",
1287
- oauthClientConfig: input.oauthClientConfig,
1288
- lastRefresh: undefined,
1289
- createdAt: now,
1290
- lastUsedAt: undefined,
1291
- failureCount: 0,
1292
- scopes: input.scopes,
1293
- };
1294
-
1295
- if (existingIdx >= 0) {
1296
- // Update preserving createdAt and enabled if not specified
1297
- const prev = this.store.accounts[existingIdx];
1298
- const merged: AccountMetadataV3 = {
1299
- ...(prev as AccountMetadataV3),
1300
- ...base,
1301
- createdAt: (prev as AccountMetadataV3).createdAt,
1302
- enabled: input.enabled ?? (prev as AccountMetadataV3).enabled,
1303
- failureCount: 0, // reset on re-add
1304
- disabledReason: undefined,
1305
- disabledUntil: undefined,
1306
- };
1307
- // If a new refreshToken came in, update it; input always carries refreshToken in our case
1308
- this.store.accounts[existingIdx] = merged;
1309
- this.logger?.info?.(`[accounts] updated existing account ${email}`);
1310
- this.saveAccounts();
1311
- return merged;
1312
- } else {
1313
- this.store.accounts.push(base);
1314
- // First account becomes active
1315
- if (!this.store.activeEmail) {
1316
- this.store.activeEmail = email;
1317
- }
1318
- this.logger?.info?.(`[accounts] added account ${email}`);
1319
- this.saveAccounts();
1320
- return base;
1321
- }
1322
- }
1323
-
1324
- /**
1325
- * Removes account by email. Returns true if removed.
1326
- */
1327
- removeAccount(email: string): boolean {
1328
- const idx = this.findAccountIndex(email);
1329
- if (idx < 0) return false;
1330
-
1331
- const removed = this.store.accounts.splice(idx, 1)[0];
1332
- this.logger?.info?.(`[accounts] removed account ${(removed as AccountMetadataV3).email}`);
1333
-
1334
- // Adjust activeEmail if it was the removed one
1335
- if (this.store.activeEmail && normalizeEmail(this.store.activeEmail) === normalizeEmail(email)) {
1336
- this.store.activeEmail = this.store.accounts.length > 0 ? this.store.accounts[0]!.email : null;
1337
- }
1338
-
1339
- // Adjust rotationIndex
1340
- if (this.rotationIdx >= this.store.accounts.length) {
1341
- this.rotationIdx = 0;
1342
- }
1343
-
1344
- this.saveAccounts();
1345
- return true;
1346
- }
1347
-
1348
- getActiveAccount(): AccountMetadataV3 | null {
1349
- const enabled = this.getEnabledAccounts();
1350
- if (enabled.length === 0) {
1351
- // Fallback: do not return an account in cooldown; return null if really empty
1352
- // If accounts exist but all are in cooldown, return the one with oldest disabledUntil (next to be released)
1353
- const all = this.store.accounts.filter((a) => a.enabled);
1354
- if (all.length === 0) return null;
1355
- const sorted = [...all].sort((a, b) => (a.disabledUntil ?? 0) - (b.disabledUntil ?? 0));
1356
- return sorted[0] ?? null;
1357
- }
1358
-
1359
- const strategy = this.store.rotationStrategy ?? "round-robin";
1360
-
1361
- if (strategy === "sticky" && this.store.activeEmail) {
1362
- const sticky = enabled.find((a) => normalizeEmail(a.email) === normalizeEmail(this.store.activeEmail!));
1363
- if (sticky) {
1364
- // Lazy update of lastUsedAt
1365
- sticky.lastUsedAt = nowMs();
1366
- return sticky;
1367
- }
1368
- // Sticky points to a disabled account - fall back to round-robin
1369
- }
1370
-
1371
- // Round-robin
1372
- if (this.rotationIdx >= enabled.length || this.rotationIdx < 0) {
1373
- this.rotationIdx = 0;
1374
- }
1375
-
1376
- const account = enabled[this.rotationIdx]!;
1377
- // Advance pointer for next call (in-memory; persisted below)
1378
- this.rotationIdx = (this.rotationIdx + 1) % enabled.length;
1379
- this.store.rotationIndex = this.rotationIdx;
1380
- this.store.activeEmail = account.email;
1381
- account.lastUsedAt = nowMs();
1382
-
1383
- // Lazy non-blocking save? To persist rotation, save but do not fail on error
1384
- try {
1385
- this.saveAccounts();
1386
- } catch {
1387
- // ignore save error in getActiveAccount to keep hot path
1388
- }
1389
-
1390
- return account;
1391
- }
1392
-
1393
- /** Sets the active account (sticky-mode override). Optionally sets strategy to sticky. */
1394
- setActive(email: string, opts?: { sticky?: boolean }): AccountMetadataV3 {
1395
- const acct = this.findAccount(email);
1396
- if (!acct) throw new Error(`setActive: account not found ${email}`);
1397
-
1398
- // If account is disabled and not in expired cooldown, still allow but warn
1399
- if (!acct.enabled) {
1400
- this.logger?.warn?.(`[accounts] setActive called for disabled account ${email}, enabling temporarily`);
1401
- }
1402
-
1403
- this.store.activeEmail = acct.email;
1404
- if (opts?.sticky) {
1405
- this.store.rotationStrategy = "sticky";
1406
- }
1407
- // Align rotationIdx so round-robin continues from here if strategy changes
1408
- const enabled = this.getEnabledAccounts();
1409
- const idx = enabled.findIndex((a) => normalizeEmail(a.email) === normalizeEmail(email));
1410
- if (idx >= 0) this.rotationIdx = idx;
1411
-
1412
- acct.lastUsedAt = nowMs();
1413
- this.saveAccounts();
1414
- return acct;
1415
- }
1416
-
1417
- enableAccount(email: string): AccountMetadataV3 {
1418
- const acct = this.findAccount(email);
1419
- if (!acct) throw new Error(`enableAccount: not found ${email}`);
1420
- acct.enabled = true;
1421
- acct.failureCount = 0;
1422
- acct.disabledReason = undefined;
1423
- acct.disabledUntil = undefined;
1424
- this.saveAccounts();
1425
- return acct;
1426
- }
1427
-
1428
- disableAccount(email: string, reason?: string, cooldownMs?: number): AccountMetadataV3 {
1429
- const acct = this.findAccount(email);
1430
- if (!acct) throw new Error(`disableAccount: not found ${email}`);
1431
- acct.enabled = false;
1432
- acct.disabledReason = reason ?? "manually disabled";
1433
- if (cooldownMs && cooldownMs > 0) {
1434
- acct.disabledUntil = nowMs() + cooldownMs;
1435
- // With cooldown, keep enabled=false until expiry; background will re-enable
1436
- }
1437
- this.saveAccounts();
1438
- return acct;
1439
- }
1440
-
1441
- // Compat aliases required by consumers
1442
- enable(email: string): AccountMetadataV3 {
1443
- return this.enableAccount(email);
1444
- }
1445
-
1446
- disable(email: string, reason?: string, cooldownMs?: number): AccountMetadataV3 {
1447
- return this.disableAccount(email, reason, cooldownMs);
1448
- }
1449
-
1450
- // --------------------------------------------------------------------------
1451
- // ROTATION ON ERROR - 429/quota detection
1452
- // --------------------------------------------------------------------------
1453
-
1454
- /**
1455
- * Detects quota/429 errors and rotates to the next enabled account.
1456
- * Accepts flexible signatures:
1457
- * - rotateOnError(error)
1458
- * - rotateOnError(email, error)
1459
- * - rotateOnError(error, email)
1460
- */
1461
- rotateOnError(errorOrEmail: unknown, maybeErrorOrEmail?: unknown): AccountMetadataV3 | null {
1462
- let currentEmail: string | undefined;
1463
- let error: unknown;
1464
-
1465
- // Signature parsing heuristic
1466
- if (typeof errorOrEmail === "string" && isValidEmail(errorOrEmail)) {
1467
- currentEmail = errorOrEmail;
1468
- error = maybeErrorOrEmail;
1469
- } else if (typeof maybeErrorOrEmail === "string" && isValidEmail(maybeErrorOrEmail as string)) {
1470
- currentEmail = maybeErrorOrEmail as string;
1471
- error = errorOrEmail;
1472
- } else {
1473
- // First arg is the error
1474
- error = errorOrEmail;
1475
- if (typeof maybeErrorOrEmail === "string" && isValidEmail(maybeErrorOrEmail as string)) {
1476
- currentEmail = maybeErrorOrEmail as string;
1477
- }
1478
- }
1479
-
1480
- if (!isQuotaError(error)) {
1481
- // Not quota - no rotation
1482
- this.logger?.debug?.(
1483
- `[accounts] rotateOnError called but not quota error: ${String((error as any)?.message ?? error).slice(0, 200)}`,
1484
- );
1485
- return null;
1486
- }
1487
-
1488
- // Resolve current account
1489
- let currentAcct: AccountMetadataV3 | undefined;
1490
- if (currentEmail) {
1491
- currentAcct = this.findAccount(currentEmail);
1492
- } else {
1493
- // Try using store activeEmail
1494
- const active = this.getActiveAccount();
1495
- if (active) {
1496
- currentAcct = active;
1497
- currentEmail = active.email;
1498
- }
1499
- }
1500
-
1501
- if (currentAcct) {
1502
- currentAcct.failureCount = (currentAcct.failureCount ?? 0) + 1;
1503
- // On consecutive failure >= 3 apply 30min cooldown
1504
- const cooldown =
1505
- (currentAcct.failureCount ?? 0) >= 3
1506
- ? 30 * 60 * 1000
1507
- : (currentAcct.failureCount ?? 0) >= 2
1508
- ? 10 * 60 * 1000
1509
- : 5 * 60 * 1000;
1510
-
1511
- currentAcct.disabledUntil = nowMs() + cooldown;
1512
- currentAcct.disabledReason = `quota/429 detected (${parseErrorStatus(error) ?? "quota"}) failureCount=${currentAcct.failureCount}`;
1513
-
1514
- // If failureCount >= 5, hard-disable until import/manual enable
1515
- if ((currentAcct.failureCount ?? 0) >= 5) {
1516
- currentAcct.enabled = false;
1517
- this.logger?.warn?.(
1518
- `[accounts] account ${currentAcct.email} disabled after ${currentAcct.failureCount} quota failures`,
1519
- );
1520
- }
1521
-
1522
- this.store.lastRotationAt = nowMs();
1523
- this.store.lastRotatedFrom = currentAcct.email;
1524
- }
1525
-
1526
- const enabled = this.getEnabledAccounts().filter((a) => {
1527
- // Exclude the current account that caused the error
1528
- if (currentEmail && normalizeEmail(a.email) === normalizeEmail(currentEmail)) return false;
1529
- return true;
1530
- });
1531
-
1532
- if (enabled.length === 0) {
1533
- // Nothing else available - try all, including current if not hard-disabled
1534
- const allEnabled = this.getEnabledAccounts();
1535
- if (allEnabled.length === 0) {
1536
- this.logger?.warn?.(`[accounts] rotateOnError: no enabled accounts left`);
1537
- try {
1538
- this.saveAccounts();
1539
- } catch {
1540
- /* the guarded best-effort operation falls through: the outer flow owns the failure */
1541
- }
1542
- return null;
1543
- }
1544
- // If only the current one was available and it is now in cooldown, nowhere to rotate
1545
- // Return null and caller must back off
1546
- this.logger?.warn?.(
1547
- `[accounts] rotateOnError: only current account ${currentEmail} was enabled, now in cooldown`,
1548
- );
1549
- try {
1550
- this.saveAccounts();
1551
- } catch {
1552
- /* the guarded best-effort operation falls through: the outer flow owns the failure */
1553
- }
1554
- return null;
1555
- }
1556
-
1557
- // Round-robin to next
1558
- if (this.rotationIdx >= enabled.length) this.rotationIdx = 0;
1559
- const next = enabled[this.rotationIdx]!;
1560
- this.rotationIdx = (this.rotationIdx + 1) % enabled.length;
1561
- this.store.rotationIndex = this.rotationIdx;
1562
- this.store.activeEmail = next.email;
1563
- next.lastUsedAt = nowMs();
1564
-
1565
- this.logger?.info?.(`[accounts] rotated from ${currentEmail ?? "unknown"} to ${next.email} due to quota error`);
1566
-
1567
- try {
1568
- this.saveAccounts();
1569
- } catch {
1570
- /* the guarded best-effort operation falls through: the outer flow owns the failure */
1571
- }
1572
-
1573
- return next;
1574
- }
1575
-
1576
- // --------------------------------------------------------------------------
1577
- // TOKEN REFRESH - double-checked locking
1578
- // --------------------------------------------------------------------------
1579
-
1580
- /**
1581
- * Refresh with double-checked locking
1582
- * - Checks expiry before lock
1583
- * - If a lock exists, waits for it and re-checks
1584
- * - Only then performs the refresh
1585
- */
1586
- async refreshAccessTokenIfNeeded(email: string, force = false): Promise<AccountMetadataV3> {
1587
- const acct = this.findAccount(email);
1588
- if (!acct) throw new Error(`refreshAccessTokenIfNeeded: account not found ${email}`);
1589
-
1590
- // Fast path - token still valid and not forced
1591
- if (!force && !isTokenExpired(acct)) {
1592
- return acct;
1593
- }
1594
-
1595
- const key = normalizeEmail(acct.email);
1596
-
1597
- // If a lock already exists, wait and re-check (double-checked)
1598
- const existingLock = this.refreshLocks.get(key);
1599
- if (existingLock) {
1600
- this.logger?.debug?.(`[accounts] waiting existing refresh lock for ${acct.email}`);
1601
- const waited = await existingLock;
1602
- // Second check after lock release
1603
- if (!force && !isTokenExpired(waited)) {
1604
- return waited;
1605
- }
1606
- // If still expired (refresh failed and kept old token?), continue to new refresh below
1607
- }
1608
-
1609
- // Create new lock
1610
- const refreshPromise = (async (): Promise<AccountMetadataV3> => {
1611
- // Double-checked locking - second check inside the lock
1612
- const fresh = this.findAccount(email);
1613
- if (!fresh) throw new Error(`Account disappeared during refresh ${email}`);
1614
-
1615
- if (!force && !isTokenExpired(fresh)) {
1616
- return fresh;
1617
- }
1618
-
1619
- this.logger?.info?.(`[accounts] refreshing access token for ${fresh.email}`);
1620
-
1621
- try {
1622
- const tokenRes = await performTokenRefresh(fresh);
1623
-
1624
- // Update account in memory (fetch latest reference again)
1625
- const targetIdx = this.findAccountIndex(email);
1626
- if (targetIdx < 0) throw new Error(`Account vanished after refresh ${email}`);
1627
- const target = this.store.accounts[targetIdx]!;
1628
-
1629
- target.accessToken = tokenRes.access_token;
1630
- target.expiryDate = nowMs() + tokenRes.expires_in * 1000;
1631
- target.lastRefresh = nowMs();
1632
- target.failureCount = 0;
1633
- target.disabledReason = undefined;
1634
- target.disabledUntil = undefined;
1635
- // Re-enable if disabled due to auth error
1636
- const prevDisabledReason = target.disabledReason as string | undefined;
1637
- if (!target.enabled && prevDisabledReason?.includes("auth")) {
1638
- target.enabled = true;
1639
- }
1640
-
1641
- // Project update? not during token step
1642
-
1643
- this.saveAccounts();
1644
- this.logger?.info?.(`[accounts] refresh OK for ${target.email}, expires in ${tokenRes.expires_in}s`);
1645
- return target;
1646
- } catch (err) {
1647
- const status = parseErrorStatus(err);
1648
- const errMsg = (err as Error).message || String(err);
1649
-
1650
- this.logger?.warn?.(`[accounts] refresh failed for ${acct.email}: ${status ?? ""} ${errMsg.slice(0, 300)}`);
1651
-
1652
- // If refresh failed with invalid_grant / 400 / 401 -> refresh token invalid, disable
1653
- if (
1654
- status === 400 ||
1655
- status === 401 ||
1656
- errMsg.includes("invalid_grant") ||
1657
- errMsg.includes("expired") ||
1658
- errMsg.includes("revoked")
1659
- ) {
1660
- const idx = this.findAccountIndex(email);
1661
- if (idx >= 0) {
1662
- const t = this.store.accounts[idx]!;
1663
- t.failureCount = (t.failureCount ?? 0) + 1;
1664
- if ((t.failureCount ?? 0) >= 2) {
1665
- t.enabled = false;
1666
- t.disabledReason = `refresh failed: ${errMsg.slice(0, 200)}`;
1667
- }
1668
- try {
1669
- this.saveAccounts();
1670
- } catch {
1671
- /* the guarded best-effort operation falls through: the outer flow owns the failure */
1672
- }
1673
- }
1674
- }
1675
-
1676
- throw err;
1677
- }
1678
- })();
1679
-
1680
- this.refreshLocks.set(key, refreshPromise);
1681
-
1682
- try {
1683
- const result = await refreshPromise;
1684
- return result;
1685
- } finally {
1686
- // Always clear lock
1687
- if (this.refreshLocks.get(key) === refreshPromise) {
1688
- this.refreshLocks.delete(key);
1689
- }
1690
- }
1691
- }
1692
-
1693
- /**
1694
- * Returns a valid access token, refreshing if needed.
1695
- * If email is omitted, uses the active account (round-robin/sticky).
1696
- */
1697
- async getValidAccessToken(email?: string): Promise<string> {
1698
- let acct: AccountMetadataV3 | null | undefined;
1699
-
1700
- if (email) {
1701
- acct = this.findAccount(email);
1702
- if (!acct) throw new Error(`getValidAccessToken: account not found ${email}`);
1703
- } else {
1704
- acct = this.getActiveAccount();
1705
- if (!acct) throw new Error(`getValidAccessToken: no active account available`);
1706
- }
1707
-
1708
- if (!acct.enabled) {
1709
- // If in cooldown but expired, lazily re-enable
1710
- if (acct.disabledUntil && acct.disabledUntil <= nowMs()) {
1711
- acct.enabled = true;
1712
- acct.disabledUntil = undefined;
1713
- acct.disabledReason = undefined;
1714
- acct.failureCount = 0;
1715
- try {
1716
- this.saveAccounts();
1717
- } catch {
1718
- /* the guarded best-effort operation falls through: the outer flow owns the failure */
1719
- }
1720
- } else {
1721
- throw new Error(`Account ${acct.email} is disabled: ${acct.disabledReason ?? "unknown"}`);
1722
- }
1723
- }
1724
-
1725
- const refreshed = await this.refreshAccessTokenIfNeeded(acct.email);
1726
- if (!refreshed.accessToken) {
1727
- throw new Error(`Failed to obtain access token for ${refreshed.email}`);
1728
- }
1729
- return refreshed.accessToken;
1730
- }
1731
-
1732
- // --------------------------------------------------------------------------
1733
- // BACKGROUND REFRESH
1734
- // --------------------------------------------------------------------------
1735
-
1736
- /**
1737
- * Background refresh of all enabled accounts close to expiry
1738
- * - Does not block callers
1739
- * - Uses allSettled so the batch never fails as a whole
1740
- */
1741
- async backgroundRefresh(): Promise<void> {
1742
- // Reload from disk first to pick up external changes
1743
- try {
1744
- this.loadAccounts();
1745
- } catch {
1746
- // ignore
1747
- }
1748
-
1749
- const enabled = this.getEnabledAccounts();
1750
- if (enabled.length === 0) return;
1751
-
1752
- const toRefresh = enabled.filter((a) => {
1753
- // Skip if future disabledUntil set
1754
- if (a.disabledUntil && a.disabledUntil > nowMs()) return false;
1755
- // If token missing or expiring within BACKGROUND buffer, refresh
1756
- return isTokenExpired(a, BACKGROUND_REFRESH_BUFFER_MS);
1757
- });
1758
-
1759
- if (toRefresh.length === 0) {
1760
- this.logger?.debug?.(`[accounts] backgroundRefresh: no accounts need refresh`);
1761
- return;
1762
- }
1763
-
1764
- this.logger?.info?.(`[accounts] backgroundRefresh: refreshing ${toRefresh.length}/${enabled.length} accounts`);
1765
-
1766
- const results = await Promise.allSettled(
1767
- toRefresh.map((a) =>
1768
- this.refreshAccessTokenIfNeeded(a.email).catch((e) => {
1769
- // Do not break allSettled with an extra throw
1770
- throw e;
1771
- }),
1772
- ),
1773
- );
1774
-
1775
- const fails = results.filter((r) => r.status === "rejected").length;
1776
- if (fails > 0) {
1777
- this.logger?.warn?.(`[accounts] backgroundRefresh: ${fails} failures`);
1778
- }
1779
- }
1780
-
1781
- /**
1782
- * Starts background refresh without awaiting (fire-and-forget)
1783
- */
1784
- triggerBackgroundRefresh(): void {
1785
- this.backgroundRefresh().catch(() => {});
1786
- }
1787
-
1788
- // --------------------------------------------------------------------------
1789
- // IMPORT FROM antigravity-manager
1790
- // --------------------------------------------------------------------------
1791
-
1792
- private static getAntigravityManagerCandidatePaths(): string[] {
1793
- const home = os.homedir();
1794
- return [
1795
- path.join(home, ".config", "antigravity-manager", "accounts.json"),
1796
- path.join(home, ".antigravity-manager", "accounts.json"),
1797
- path.join(home, ".config", "antigravity-manager", "credentials.json"),
1798
- path.join(home, ".config", "antigravity", "accounts.json"),
1799
- path.join(home, ".antigravity", "accounts.json"),
1800
- path.join(home, ".antigravity", "auth.json"),
1801
- path.join(home, ".config", "antigravity", "auth.json"),
1802
- path.join(home, ".gemini", "antigravity", "accounts.json"),
1803
- path.join(home, ".config", "opencode", "auth", "antigravity-accounts.json"),
1804
- path.join(home, ".config", "opencode", "auth", "gemini-cli-tokens.json"),
1805
- path.join(home, ".config", "opencode", "antigravity.json"),
1806
- // Legacy antigravity-manager sqlite? No, json only.
1807
- ];
1808
- }
1809
-
1810
- private static extractAccountsFromRaw(raw: unknown): unknown[] {
1811
- if (!raw) return [];
1812
-
1813
- // Case 1: direct array
1814
- if (Array.isArray(raw)) return raw;
1815
-
1816
- if (typeof raw !== "object") return [];
1817
-
1818
- const obj = raw as Record<string, unknown>;
1819
-
1820
- // Case 2: { accounts: [...] }
1821
- if (Array.isArray(obj.accounts)) return obj.accounts as unknown[];
1822
-
1823
- // Case 3: { credentials: [...] } or { tokens: [...] }
1824
- if (Array.isArray(obj.credentials)) return obj.credentials as unknown[];
1825
- if (Array.isArray(obj.tokens)) return obj.tokens as unknown[];
1826
-
1827
- // Case 4: { <email>: { refresh_token, ... } } map
1828
- const keys = Object.keys(obj);
1829
- const looksLikeEmailMap = keys.some((k) => k.includes("@"));
1830
- if (looksLikeEmailMap) {
1831
- return keys.map((k) => {
1832
- const v = obj[k];
1833
- if (typeof v === "object" && v !== null) {
1834
- return { email: k, ...(v as object) };
1835
- }
1836
- return { email: k, refreshToken: v };
1837
- });
1838
- }
1839
-
1840
- // Case 5: { version: ..., data: [...] } wrapper
1841
- if (Array.isArray(obj.data)) return obj.data as unknown[];
1842
- if (obj.data && typeof obj.data === "object") {
1843
- const inner = obj.data as Record<string, unknown>;
1844
- if (Array.isArray(inner.accounts)) return inner.accounts as unknown[];
1845
- }
1846
-
1847
- // Case 6: single account object
1848
- if ((obj.email as string) || (obj.refresh_token as string) || (obj.refreshToken as string)) {
1849
- return [raw];
1850
- }
1851
-
1852
- return [];
1853
- }
1854
-
1855
- /**
1856
- * Imports accounts from existing antigravity-manager installations.
1857
- * Tries multiple paths and legacy formats (including the flat Account[]
1858
- * reader), merging into the current store without duplicates.
1859
- */
1860
- async importFromAntigravityManager(): Promise<ImportResult> {
1861
- // Union of classic candidates and the portable resolver
1862
- // (adds ANTIGRAVITY_MANAGER_ACCOUNTS_PATH env + macOS/Windows AppData locations)
1863
- const merged = new Set<string>(AccountManager.getAntigravityManagerCandidatePaths());
1864
- for (const c of resolveAntagravityManagerCandidates()) merged.add(c);
1865
- const candidates = Array.from(merged);
1866
-
1867
- const result: ImportResult = {
1868
- imported: 0,
1869
- skipped: 0,
1870
- updated: 0,
1871
- errors: [],
1872
- sources: [],
1873
- accounts: [],
1874
- };
1875
-
1876
- // Reload current state for safe merge
1877
- try {
1878
- this.loadAccounts();
1879
- } catch {
1880
- /* the guarded best-effort operation falls through: the outer flow owns the failure */
1881
- }
1882
-
1883
- const existingEmails = new Set(this.store.accounts.map((a) => normalizeEmail(a.email)));
1884
-
1885
- for (const p of candidates) {
1886
- // Skip our own target file to avoid re-importing itself
1887
- if (path.resolve(p) === path.resolve(this.storePath)) continue;
1888
-
1889
- if (!fs.existsSync(p)) continue;
1890
-
1891
- try {
1892
- const content = fs.readFileSync(p, { encoding: "utf8" });
1893
- const parsed = JSON.parse(content);
1894
- const rawAccounts = AccountManager.extractAccountsFromRaw(parsed);
1895
-
1896
- if (rawAccounts.length === 0) {
1897
- // Fall back to the strict flat-file reader for exotic shapes
1898
- const legacy = parseLegacyAccounts(parsed);
1899
- if (legacy.accounts.length > 0) {
1900
- rawAccounts.push(...legacy.accounts);
1901
- }
1902
- }
1903
-
1904
- if (rawAccounts.length === 0) {
1905
- result.errors.push(`${p}: no accounts extracted`);
1906
- continue;
1907
- }
1908
-
1909
- result.sources.push(p);
1910
- this.logger?.info?.(`[accounts] import candidate ${p} -> ${rawAccounts.length} raw`);
1911
-
1912
- for (const raw of rawAccounts) {
1913
- const normalized = normalizeAccountRaw(raw);
1914
- if (!normalized) {
1915
- result.skipped++;
1916
- continue;
1917
- }
1918
-
1919
- const normEmail = normalizeEmail(normalized.email);
1920
- if (existingEmails.has(normEmail)) {
1921
- // Exists already: update if new one has newer accessToken or old had failures
1922
- const existing = this.findAccount(normEmail);
1923
- if (existing) {
1924
- const shouldUpdate =
1925
- (normalized.accessToken &&
1926
- normalized.expiryDate &&
1927
- (!existing.expiryDate || normalized.expiryDate > existing.expiryDate)) ||
1928
- (existing.failureCount ?? 0) > 0;
1929
-
1930
- if (shouldUpdate) {
1931
- existing.refreshToken = normalized.refreshToken;
1932
- if (normalized.accessToken) existing.accessToken = normalized.accessToken;
1933
- if (normalized.expiryDate) existing.expiryDate = normalized.expiryDate;
1934
- if (normalized.projectId) existing.projectId = normalized.projectId;
1935
- existing.failureCount = 0;
1936
- existing.disabledReason = undefined;
1937
- existing.disabledUntil = undefined;
1938
- existing.enabled = true;
1939
- result.updated++;
1940
- } else {
1941
- result.skipped++;
1942
- }
1943
- } else {
1944
- result.skipped++;
1945
- }
1946
- continue;
1947
- }
1948
-
1949
- // Force antigravity-manager client key for tracking
1950
- if (!normalized.oauthClientKey || normalized.oauthClientKey === "gemini-cli") {
1951
- normalized.oauthClientKey = "antigravity-manager";
1952
- }
1953
- // Guarantee default oauth config when missing
1954
- if (!normalized.oauthClientConfig) {
1955
- normalized.oauthClientConfig = {
1956
- clientId: GEMINI_CLI_OAUTH_CLIENT_ID,
1957
- clientSecret: GEMINI_CLI_OAUTH_CLIENT_SECRET,
1958
- };
1959
- }
1960
-
1961
- this.store.accounts.push(normalized);
1962
- existingEmails.add(normEmail);
1963
- result.imported++;
1964
- result.accounts.push(normalized);
1965
- }
1966
- } catch (e) {
1967
- result.errors.push(`${p}: ${(e as Error).message}`);
1968
- }
1969
- }
1970
-
1971
- if (result.imported > 0 || result.updated > 0) {
1972
- try {
1973
- this.saveAccounts();
1974
- } catch (e) {
1975
- result.errors.push(`save after import failed: ${(e as Error).message}`);
1976
- }
1977
- }
1978
-
1979
- this.logger?.info?.(
1980
- `[accounts] import finished: imported=${result.imported} updated=${result.updated} skipped=${result.skipped} sources=${result.sources.length}`,
1981
- );
1982
-
1983
- return result;
1984
- }
1985
-
1986
- /** Alias compat requested by consumers */
1987
- async importFromAntigravity(): Promise<ImportResult> {
1988
- return this.importFromAntigravityManager();
1989
- }
1990
-
1991
- /** Additional alias for backwards compatibility */
1992
- async importAccounts(): Promise<ImportResult> {
1993
- return this.importFromAntigravityManager();
1994
- }
1995
-
1996
- // --------------------------------------------------------------------------
1997
- // LIFECYCLE / UTIL
1998
- // --------------------------------------------------------------------------
1999
-
2000
- /** Clears the background timer (useful in tests) */
2001
- destroy(): void {
2002
- if (this.backgroundTimer) {
2003
- clearInterval(this.backgroundTimer);
2004
- this.backgroundTimer = undefined;
2005
- }
2006
- this.refreshLocks.clear();
2007
- }
2008
-
2009
- /** File path */
2010
- getStorePath(): string {
2011
- return this.storePath;
2012
- }
2013
-
2014
- /** Raw store (clone) */
2015
- getStore(): AccountStore {
2016
- return {
2017
- ...this.store,
2018
- accounts: [...this.store.accounts],
2019
- };
2020
- }
2021
-
2022
- /** Sets the rotation strategy */
2023
- setRotationStrategy(strategy: RotationStrategy): void {
2024
- this.store.rotationStrategy = strategy;
2025
- this.saveAccounts();
2026
- }
2027
- }
2028
-
2029
- // ============================================================================
2030
- // SECTION 08. SINGLETON DEFAULT + EXPORT HELPERS
2031
- // ============================================================================
2032
-
2033
- let _defaultManager: AccountManager | null = null;
2034
-
2035
- /** Returns the default lazy singleton instance */
2036
- export function getDefaultAccountManager(opts?: AccountManagerOptions): AccountManager {
2037
- if (!_defaultManager) {
2038
- _defaultManager = new AccountManager(opts);
2039
- }
2040
- return _defaultManager;
2041
- }
2042
-
2043
- /** Resets the singleton (tests) */
2044
- export function resetDefaultAccountManager(): void {
2045
- if (_defaultManager) {
2046
- _defaultManager.destroy();
2047
- _defaultManager = null;
2048
- }
2049
- }
2050
-
2051
- /** Quick helper to obtain a valid token using the singleton */
2052
- export async function getValidAccessToken(email?: string, opts?: AccountManagerOptions): Promise<string> {
2053
- const mgr = getDefaultAccountManager(opts);
2054
- return mgr.getValidAccessToken(email);
2055
- }
2056
-
2057
- /** Quick helper to obtain the active account */
2058
- export function getActiveAccount(opts?: AccountManagerOptions): AccountMetadataV3 | null {
2059
- const mgr = getDefaultAccountManager(opts);
2060
- return mgr.getActiveAccount();
2061
- }
2062
-
2063
- // ============================================================================
2064
- // SECTION 09. DEFAULT EXPORT - CJS/ESM compat
2065
- // ============================================================================
2066
-
2067
- export default AccountManager;
2068
-
2069
- export const ACCOUNTS_FILE_PATH = resolveAccountsFilePath();
2070
-
2071
- // local variant: diverges from constants (different barrel shape — accounts-specific fields)
2072
- export const ACCOUNTS_INFO = {
2073
- CLIENT_ID_RAW: GEMINI_CLI_CLIENT_ID_RAW,
2074
- CLIENT_ID: GEMINI_CLI_OAUTH_CLIENT_ID,
2075
- CLIENT_SECRET: GEMINI_CLI_OAUTH_CLIENT_SECRET,
2076
- SCOPES: GEMINI_CLI_SCOPES,
2077
- TOKEN_URL: OAUTH_TOKEN_URL,
2078
- USER_AGENT: GEMINI_CLI_USER_AGENT,
2079
- USER_AGENT_FULL: GEMINI_CLI_USER_AGENT_FULL,
2080
- X_GOOG_API_CLIENT,
2081
- CLIENT_METADATA,
2082
- CLOUDCODE_BASE_URL,
2083
- CLOUDCODE_ENDPOINTS,
2084
- PROJECT_FALLBACK,
2085
- MODELS_2026,
2086
- ACCOUNTS_FILE_PATH,
2087
- } as const;