dondo-donuts 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ragaeeb Haq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # Dondo
2
+
3
+ <p>
4
+ <img src="./icon.png" alt="Dondo icon" width="96" height="96" />
5
+ </p>
6
+
7
+ [![npm](https://img.shields.io/npm/v/dondo?color=111827)](https://www.npmjs.com/package/dondo)
8
+ [![Bun](https://img.shields.io/badge/runtime-Bun-fbf0df?logo=bun&logoColor=000)](https://bun.sh)
9
+ [![TypeScript](https://img.shields.io/badge/code-TypeScript-3178c6?logo=typescript&logoColor=fff)](https://www.typescriptlang.org)
10
+ [![Preact](https://img.shields.io/badge/ui-Preact-673ab8?logo=preact&logoColor=fff)](https://preactjs.com)
11
+ [![Biome](https://img.shields.io/badge/lint-Biome-60a5fa?logo=biome&logoColor=fff)](https://biomejs.dev)
12
+ [![macOS](https://img.shields.io/badge/platform-macOS-111827?logo=apple&logoColor=fff)](https://www.apple.com/macos)
13
+ [![Antigravity](https://img.shields.io/badge/switches-Antigravity-2563eb)](https://antigravity.google)
14
+ [![Codex](https://img.shields.io/badge/switches-Codex-10a37f)](https://openai.com/codex)
15
+ [![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](./LICENSE)
16
+ [![GitHub issues](https://img.shields.io/github/issues/ragaeeb/dondo?color=6f42c1)](https://github.com/ragaeeb/dondo/issues)
17
+ [![wakatime](https://wakatime.com/badge/user/a0b906ce-b8e7-4463-8bce-383238df6d4b/project/1c226a67-6f05-42d3-a8c3-591ef0fa09fd.svg)](https://wakatime.com/badge/user/a0b906ce-b8e7-4463-8bce-383238df6d4b/project/1c226a67-6f05-42d3-a8c3-591ef0fa09fd)
18
+
19
+ Dondo is a small local Bun app for saving and switching local AI tool accounts. It starts a local web UI, stores saved accounts in an encrypted local vault, and currently supports Antigravity and Codex.
20
+
21
+ Current platform support is macOS. Dondo uses the macOS `security` CLI for the local vault key, and Antigravity account switching uses macOS Keychain entries.
22
+
23
+ ## Install
24
+
25
+ Install Bun 1.3 or newer, then run:
26
+
27
+ ```sh
28
+ bunx dondo
29
+ ```
30
+
31
+ Then open:
32
+
33
+ ```text
34
+ http://localhost:3000
35
+ ```
36
+
37
+ The server binds to `127.0.0.1` only.
38
+
39
+ ## Development
40
+
41
+ ```sh
42
+ bun install
43
+ bun run start
44
+ ```
45
+
46
+ Useful checks:
47
+
48
+ ```sh
49
+ bun run typecheck
50
+ bun run lint
51
+ bun test
52
+ bun build src/server.ts --target=bun --outdir /tmp/dondo-build
53
+ ```
54
+
55
+ ## Storage
56
+
57
+ Dondo stores its vault at the platform data directory:
58
+
59
+ - macOS: `~/Library/Application Support/Dondo/vault.json`
60
+ - Windows: `%LOCALAPPDATA%/Dondo/Data/vault.json`
61
+ - Linux: `$XDG_DATA_HOME/dondo/vault.json` or `~/.local/share/dondo/vault.json`
62
+
63
+ Set `DONDO_DATA_DIR` to override the directory, or `ANTIGRAVITY_VAULT` to override the full vault path.
64
+ `DONDO_VAULT` also overrides the full vault path and takes precedence over the historical `ANTIGRAVITY_VAULT` name.
65
+
66
+ Vault shape:
67
+
68
+ ```json
69
+ {
70
+ "antigravity": {
71
+ "data": {},
72
+ "limits": {}
73
+ },
74
+ "codex": {
75
+ "data": {},
76
+ "limits": {}
77
+ }
78
+ }
79
+ ```
80
+
81
+ `antigravity.data` stores saved account snapshots. The token-bearing `password` field is encrypted with AES-256-GCM before writing to disk. Non-secret metadata such as labels, service names, and timestamps remains readable in the vault so the UI can list accounts. The encryption key is a random local secret stored in macOS Keychain as `dondo / vault-key`.
82
+
83
+ `antigravity.limits` stores cached rate-limit data. Dondo fetches missing limits on first load and refreshes cached limits only when the UI `Refresh limits` button is used.
84
+
85
+ `codex.data` stores encrypted snapshots of `~/.codex/auth.json`. Loading a saved Codex account writes that snapshot back to `~/.codex/auth.json` with `0600` permissions.
86
+
87
+ `codex.limits` stores cached Codex ChatGPT usage data. Dondo fetches missing limits on first load and refreshes cached limits only when the UI `Refresh limits` button is used.
88
+
89
+ Each limit cache entry has this shape:
90
+
91
+ ```json
92
+ {
93
+ "fetchedAt": "2026-06-02T00:00:00.000Z",
94
+ "quota": {
95
+ "ok": true,
96
+ "tier": "plus",
97
+ "expires": "",
98
+ "models": {}
99
+ }
100
+ }
101
+ ```
102
+
103
+ Encrypted strings use the `enc:v1:` envelope: AES-256-GCM with a 12-byte IV, 16-byte auth tag, then ciphertext, base64 encoded.
104
+
105
+ ## Environment
106
+
107
+ ```sh
108
+ DONDO_PORT=3000
109
+ PORT=3000
110
+ DONDO_DATA_DIR=/custom/data/dir
111
+ DONDO_VAULT=/custom/vault.json
112
+ ANTIGRAVITY_VAULT=/custom/vault.json
113
+ ANTIGRAVITY_SERVICE=gemini
114
+ ANTIGRAVITY_ACCOUNT=antigravity
115
+ ANTIGRAVITY_KEYCHAIN=login.keychain-db
116
+ ANTIGRAVITY_VERSION=2.0.3
117
+ GOOGLE_CLIENT_ID=...
118
+ GOOGLE_CLIENT_SECRET=...
119
+ CODEX_AUTH_PATH=~/.codex/auth.json
120
+ ```
121
+
122
+ `DONDO_PORT` takes precedence over `PORT`. `ANTIGRAVITY_KEYCHAIN` is passed as the keychain argument to macOS `security` commands, for example `login.keychain-db` or an absolute keychain path.
123
+
124
+ Antigravity limit refreshes require `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Dondo does not ship Google OAuth credentials in source.
125
+
126
+ ## Local API
127
+
128
+ All API requests must be sent to localhost. Mutating routes require `POST` with a JSON object body.
129
+
130
+ - `GET /api/antigravity/state`
131
+ - `POST /api/antigravity/limits/refresh` with optional `{ "key": "label" }`
132
+ - `POST /api/antigravity/save` with `{ "key": "label" }`
133
+ - `POST /api/antigravity/load` with `{ "key": "label" }`
134
+ - `POST /api/antigravity/clear`
135
+ - `GET /api/codex/state`
136
+ - `POST /api/codex/limits/refresh` with optional `{ "key": "label" }`
137
+ - `POST /api/codex/save` with `{ "key": "label" }`
138
+ - `POST /api/codex/load` with `{ "key": "label" }`
139
+
140
+ `Clear live` deletes the live Antigravity Keychain item plus these local Antigravity state paths:
141
+
142
+ - `~/.antigravity-agent/cloud_accounts.db`
143
+ - `~/.gemini/antigravity`
144
+ - `~/.gemini/antigravity-ide`
145
+ - `~/.gemini/antigravity-backup`
146
+ - `~/Library/Application Support/Antigravity`
147
+
148
+ ## Security Model
149
+
150
+ Dondo is designed to be easy to inspect:
151
+
152
+ - The server listens on `127.0.0.1`.
153
+ - Tokens are not returned to the browser API.
154
+ - Saved token payloads are encrypted at rest.
155
+ - Rate-limit API calls happen server-side.
156
+ - Codex `auth.json` contents are never rendered or returned by the API.
157
+
158
+ This protects against casual plaintext scraping of the vault file. A process running as the same logged-in user may still be able to access local Keychain items depending on operating-system policy. Antigravity restore currently passes the token blob to the macOS `security` CLI as an argument, which can be visible briefly to same-user process listings.
159
+
160
+ ## License
161
+
162
+ MIT
package/icon.png ADDED
Binary file
package/icon.svg ADDED
@@ -0,0 +1,69 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 125 33">
2
+ <!-- SVG created with Arrow, by QuiverAI (https://quiver.ai) -->
3
+ <style type="text/css">.cls-0 {fill:url(#SVGID_1_);}
4
+ .cls-1 {fill:url(#SVGID_2_);}
5
+ .cls-2 {fill:#381C11;}
6
+ .cls-3 {fill:url(#SVGID_3_);}
7
+ .cls-4 {fill:url(#SVGID_4_);}
8
+ .cls-5 {fill:url(#SVGID_5_);}
9
+ .cls-6 {fill:url(#SVGID_6_);}
10
+ .cls-7 {fill:url(#SVGID_7_);}
11
+ .cls-8 {fill:url(#SVGID_8_);}
12
+ .cls-9 {fill:url(#SVGID_9_);}</style>
13
+ <linearGradient id="SVGID_1_" x1="39.86" x2="39.86" y1="6.582" y2="32.31" gradientUnits="userSpaceOnUse">
14
+ <stop stop-color="#F7B678" offset="0"/>
15
+ <stop stop-color="#B05A1B" offset="1"/>
16
+ </linearGradient>
17
+ <path class="cls-0" d="m39.9 6.6c-6.5 0.1-12.8 5.4-12.8 13.2 0 6.2 3.9 12.5 12.8 12.5 7.7 0 12.9-4.9 12.9-12.5 0-7.3-5.4-13.2-12.9-13.2zm0 16c-2 0-3.6-1.3-3.6-2.9 0-1.8 1.6-3.3 3.6-3.3s3.9 1.3 3.9 3.3c0 1.8-1.7 2.9-3.9 2.9z"/>
18
+ <linearGradient id="SVGID_2_" x1="39.79" x2="39.79" y1="6.61" y2="30.82" gradientUnits="userSpaceOnUse">
19
+ <stop stop-color="#FFA7BC" offset="0"/>
20
+ <stop stop-color="#FC6986" offset="1"/>
21
+ </linearGradient>
22
+ <path class="cls-1" d="m40.1 6.6c-6.6 0-13 4.9-13 12.5 0 3.2 1.3 3.2 1.6 4.7 0.6 2.9 2 3.1 3.3 3.2 1.8 0.1 2.9 3.6 6.4 3.6 2.3 0 3.3-1.7 4.7-1.7 1.1 0 1.5 0.6 2.3 0.7 2.2 0.2 2.5-2.9 4-3.7 1.1-0.5 1.4-1 1.6-2.1 0.4-1.6 1.8-1.2 1.8-4.6 0-6.8-5.4-12.6-12.7-12.6zm-0.3 16c-2 0-4.1-1.5-4.1-3.8 0-1.8 1.8-4 4.1-4 2.1 0 4.4 1.6 4.4 3.9 0 2.2-1.9 3.9-4.4 3.9z"/>
23
+ <linearGradient id="SVGID_3_" x1="11.45" x2="11.45" y1="1.091" y2="30.69" gradientUnits="userSpaceOnUse">
24
+ <stop stop-color="#663B28" offset="0"/>
25
+ <stop stop-color="#47271A" offset="1"/>
26
+ </linearGradient>
27
+ <path class="cls-3" d="m12.1 1.1h-9.9c-0.6 0-1 0.4-1 1.2v27.2c0 0.7 0.3 1.1 1 1.1h10c7.4 0.1 14.1-3.8 14.1-14.5 0-8.2-6-15-14.2-15zm-0.7 24.1h-3.8v-17.7h3.8c5.2 0 8.2 3.4 8.2 8.8s-2.6 8.9-8.2 8.9z"/>
28
+ <linearGradient id="SVGID_4_" x1="63.62" x2="63.62" y1="9.11" y2="30.69" gradientUnits="userSpaceOnUse">
29
+ <stop stop-color="#663B28" offset="0"/>
30
+ <stop stop-color="#47271A" offset="1"/>
31
+ </linearGradient>
32
+ <path class="cls-4" d="m66.1 9.1c-1.8 0-3.8 0.8-5.4 2.1-0.2 0-0.4-0.1-0.4-0.3 0.1-0.7-0.6-1.3-1.4-1.3h-3.4c-0.7 0-1.2 0.5-1.2 1.3v18.5c0 0.7 0.4 1.2 1.1 1.2h3.7c0.7 0 1.1-0.5 1.1-1.2v-10c0-2.5 1.7-4.4 4-4.4s3.7 1.7 3.7 4.2v10.2c0 0.7 0.4 1.2 1.1 1.2h4c0.7 0 1-0.5 1-1.2v-11.1c0-5.4-2.9-9.2-7.9-9.2z"/>
33
+ <linearGradient id="SVGID_5_" x1="87.21" x2="87.21" y1=".2488" y2="31.14" gradientUnits="userSpaceOnUse">
34
+ <stop stop-color="#663B28" offset="0"/>
35
+ <stop stop-color="#47271A" offset="1"/>
36
+ </linearGradient>
37
+ <path class="cls-5" d="m96.9 0.2h-3.8c-0.6 0-1.3 0.4-1.3 1.2v8.6c0 0.3-0.3 0.5-0.6 0.3-1.2-0.8-2.7-1.5-5.1-1.5-4.7 0-10.4 4-10.4 11.1 0 6.7 4.4 11.2 10.3 11.2 2.3 0 4-0.5 5.8-1.8 0.2-0.2 0.4-0.1 0.4 0.2 0 0.7 0.4 1.2 1.1 1.2h3.5c0.8 0 1.2-0.5 1.2-1.3v-28c0-0.7-0.4-1.2-1.1-1.2zm-10.2 25.2c-2.6 0-5-1.9-5-5.4 0-3.3 2.6-5.3 5-5.3s5.2 1.7 5.2 5.3c0 3.3-2.5 5.4-5.2 5.4z"/>
38
+ <linearGradient id="SVGID_6_" x1="112.3" x2="112.3" y1="6.987" y2="32.26" gradientUnits="userSpaceOnUse">
39
+ <stop stop-color="#E99449" offset="0"/>
40
+ <stop stop-color="#B05A1B" offset="1"/>
41
+ </linearGradient>
42
+ <path class="cls-6" d="m112.3 7c-6.4 0-12 4.9-12.7 12.1-0.2 5.4 3.1 13.1 12.9 13.1 7.3 0 11.4-5.7 11.4-12 0-7.2-5.2-13.2-11.6-13.2zm0 15.8c-2 0.1-4.1-1.2-4.1-3.1 0-2.1 2-3.3 4-3.3 1.8 0.1 3.6 1.3 3.6 3.1 0 1.9-1.5 3.2-3.5 3.3z"/>
43
+ <linearGradient id="SVGID_7_" x1="111.4" x2="111.4" y1="7.009" y2="30.57" gradientUnits="userSpaceOnUse">
44
+ <stop stop-color="#97E3FF" offset="0"/>
45
+ <stop stop-color="#54CCFF" offset=".4533"/>
46
+ <stop stop-color="#35A9E1" offset="1"/>
47
+ </linearGradient>
48
+ <path class="cls-7" d="m112.5 7c-6.6-0.3-12.5 4.6-12.9 11.6-0.1 4 1.5 4.1 1.8 5.4 0.8 3.4 2.3 2.9 3.6 3.3 1.5 0.4 2 3.1 5.8 3.3 1.8 0 2.8-1.5 4.2-1.5 0.8 0 1.1 0.3 1.7 0.3 1.8-0.1 1.8-2.2 3.2-2.6 1.9-0.5 1.9-1.5 2.3-2.9 0.4-1.5 1.6-0.9 1.7-4.4 0.2-6.4-4.6-12.2-11.4-12.5zm-0.3 15.8c-2.4 0.2-4.1-1.3-4.1-3.8 0-2.1 2-3.9 4.2-3.8s3.8 1.6 3.8 4c0 1.8-1.4 3.4-3.9 3.6z"/>
49
+ <linearGradient id="SVGID_8_" x1="29.67" x2="39.21" y1="16.01" y2="11.06" gradientUnits="userSpaceOnUse">
50
+ <stop stop-color="#fff" stop-opacity=".5" offset="0"/>
51
+ <stop stop-color="#fff" stop-opacity="0" offset="1"/>
52
+ </linearGradient>
53
+ <path class="cls-8" d="m38.9 8.4c-5.6 0-9.5 4.4-9.5 8.9 1.4 0.3 1.2-5.1 9.5-7.7 0.9-0.3 0.9-1.2 0-1.2z"/>
54
+ <linearGradient id="SVGID_9_" x1="102.2" x2="111.5" y1="13.97" y2="11.11" gradientUnits="userSpaceOnUse">
55
+ <stop stop-color="#fff" stop-opacity=".8" offset="0"/>
56
+ <stop stop-color="#fff" stop-opacity="0" offset="1"/>
57
+ </linearGradient>
58
+ <path class="cls-9" d="m110.6 8.9c-4.8 0.2-9.1 4.5-8.8 8.9 1.4 0.3 1.2-6.1 8.8-7.8 0.8-0.2 0.8-1.2 0-1.1z"/>
59
+ <path class="cls-2" d="m12.1 1.1c-2.9 0-7.4 0.1-9.8 0.1-0.8 0-0.7 0.4-0.7 0.9v27.4c0 0.5 0.2 0.7 0.7 0.7h9.8c6.8 0 14-2.7 14.2-14.1 0 10.3-5.1 14.6-13.9 14.6h-10.2c-0.7 0-1.2-0.3-1.2-1.2v-27.2c0-0.6 0.3-1.3 1.1-1.3l10 0.1z"/>
60
+ <path class="cls-2" d="m7.2 7h3.9c4.4 0 8.7 3.2 9.1 8.9-0.6-4.7-3.6-8.4-8.8-8.4h-3.8l-0.4-0.5z"/>
61
+ <path class="cls-2" d="m54.7 10.7v18.7c0 0.3 0.2 0.6 0.6 0.6h3.8c0.6 0 0.9-0.2 0.9-0.7 0 1.1-0.3 1.4-0.9 1.4h-3.8c-0.6 0-1.1-0.4-1.1-1.3v-18.7c0-0.7 0.4-1.2 1.3-1.2l3.6 0.1h-3.6c-0.6 0-0.8 0.5-0.8 1.1z"/>
62
+ <path class="cls-2" d="m60.2 17.6c0.5-1.8 1.9-3.3 4-3.3 3.1 0 4.2 2.5 4.2 5.5-0.3-2.8-1.8-4.8-4.2-4.8-1.7 0.1-3.4 1.1-4 2.6z"/>
63
+ <path class="cls-2" d="m68.5 29.4c0 0.6 0.2 0.8 0.7 0.8h3.7c0.6 0 0.8-0.3 0.8-0.8v-12.3c0.2 0.9 0.2 2 0.2 3.1v9.3c0 0.7-0.2 1.2-1 1.2h-3.9c-0.6 0-1.1-0.4-1.1-1.1l0.6-0.2z"/>
64
+ <path class="cls-2" d="m76.2 20.5c0-6.3 4.7-11.3 9.9-11.3 2 0 3.6 0.6 5.2 1.6 0.7 0.5 1.3 0.5 1.3-0.5v-8.9c0-0.6 0.3-1.2 1.2-1.2l3.1 0.1h-3.6c-0.6 0-0.8 0.4-0.8 1v8.7c0 0.6-0.3 0.8-0.9 0.4-1.3-0.8-2.7-1.6-5.5-1.6-4.5 0-10.4 3.7-10.4 11.1 0 4.9 2.8 10.6 10.3 10.7 1 0 3.4 0.1 5.9-1.4-2.1 1.5-3.5 1.9-5.9 1.9-6 0-9.8-4.6-9.8-10.6z"/>
65
+ <path class="cls-2" d="m81.6 20.5c0-3.3 2.2-6.6 5.4-6.7 3.3-0.1 5.6 2.7 5.6 5.9-0.2-2.8-2.7-5-5.5-5-3 0-5.4 2.4-5.5 5.8z"/>
66
+ <path class="cls-2" d="m92.4 28.7 0.2 0.8c0 0.7 0.3 1.2 0.9 1.2h3.4c0.6 0 0.9-0.5 0.9-1.2v-27.9l0.2-0.1v28c0 0.7-0.3 1.3-1.1 1.3h-3.6c-0.7 0-1.4-0.4-1.3-1.5l0.4-0.6z"/>
67
+ <path class="cls-2" d="m102.4 26.1c0.9 1.2 2 0.8 2.9 1.1 1.4 0.4 1.9 2.9 5.3 3.2 1.9 0.1 2.9-1.4 4.4-1.4 0.8 0 0.9 0.4 1.7 0.3-2.3 0.8-2.5-0.4-4.2 0.6-0.7 0.5-1.3 0.7-1.9 0.7-3.6 0-4-3-5.7-3.3-1.1-0.1-1.7 0-2.5-1.2z"/>
68
+ <path class="cls-2" d="m29.9 26c0.8 1.1 1.8 0.8 2.7 1 1.5 0.4 2.4 3.4 5.8 3.5 1.8 0 3-1.8 4.7-1.8 0.8 0 1.1 0.4 2.2 0.7-1.9 0.5-2.2-0.7-3.9 0.4-0.8 0.6-1.6 0.9-3 0.9-3.3-0.1-4.3-3.3-6-3.5-1-0.2-1.7 0-2.5-1.2z"/>
69
+ </svg>
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "author": {
3
+ "name": "Ragaeeb Haq",
4
+ "url": "https://github.com/ragaeeb"
5
+ },
6
+ "bin": {
7
+ "dondo": "src/server.ts"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/ragaeeb/dondo/issues"
11
+ },
12
+ "dependencies": {
13
+ "preact": "^10.29.2"
14
+ },
15
+ "description": "Minimal local UI for switching Antigravity and Codex accounts.",
16
+ "devDependencies": {
17
+ "@biomejs/biome": "^2.4.16",
18
+ "@types/bun": "^1.3.14",
19
+ "typescript": "^6.0.3"
20
+ },
21
+ "engines": {
22
+ "bun": ">=1.3.14"
23
+ },
24
+ "files": [
25
+ "LICENSE",
26
+ "README.md",
27
+ "icon.png",
28
+ "icon.svg",
29
+ "src"
30
+ ],
31
+ "homepage": "https://github.com/ragaeeb/dondo",
32
+ "keywords": [
33
+ "antigravity",
34
+ "codex",
35
+ "account-switcher",
36
+ "bun",
37
+ "keychain"
38
+ ],
39
+ "license": "MIT",
40
+ "name": "dondo-donuts",
41
+ "os": [
42
+ "darwin"
43
+ ],
44
+ "packageManager": "bun@1.3.14",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/ragaeeb/dondo.git"
48
+ },
49
+ "scripts": {
50
+ "format": "biome check . --write",
51
+ "lint": "biome lint .",
52
+ "start": "bun src/server.ts",
53
+ "test": "bun test",
54
+ "typecheck": "tsc --noEmit"
55
+ },
56
+ "type": "module",
57
+ "version": "0.1.0"
58
+ }
@@ -0,0 +1,70 @@
1
+ import { afterEach, expect, it } from 'bun:test';
2
+ import { decodeToken, fetchLimits } from './google.ts';
3
+
4
+ const originalFetch = globalThis.fetch;
5
+ const originalClientId = process.env.GOOGLE_CLIENT_ID;
6
+ const originalClientSecret = process.env.GOOGLE_CLIENT_SECRET;
7
+
8
+ afterEach(() => {
9
+ globalThis.fetch = originalFetch;
10
+ process.env.GOOGLE_CLIENT_ID = originalClientId;
11
+ process.env.GOOGLE_CLIENT_SECRET = originalClientSecret;
12
+ });
13
+
14
+ it('should decode go-keyring base64 token payloads', () => {
15
+ const payload = { auth_method: 'oauth-personal', token: { access_token: 'access', refresh_token: 'refresh' } };
16
+ const encoded = `go-keyring-base64:${Buffer.from(JSON.stringify(payload)).toString('base64')}`;
17
+
18
+ expect(decodeToken(encoded)).toEqual(payload);
19
+ });
20
+
21
+ it('should return null for invalid token payloads', () => {
22
+ expect(decodeToken('not base64 json')).toBeNull();
23
+ });
24
+
25
+ it('should return an updated snapshot password after refreshing an expired token', async () => {
26
+ process.env.GOOGLE_CLIENT_ID = 'test-client';
27
+ process.env.GOOGLE_CLIENT_SECRET = 'test-secret';
28
+ globalThis.fetch = (async (url: string | URL | Request) => {
29
+ const target = String(url);
30
+ if (target.includes('/token')) {
31
+ return Response.json({ access_token: 'new-access', expires_in: 3600 });
32
+ }
33
+ if (target.includes('loadCodeAssist')) {
34
+ return Response.json({
35
+ cloudaicompanionProject: 'project',
36
+ paidTier: { name: 'plus' },
37
+ });
38
+ }
39
+ return Response.json({
40
+ models: {
41
+ 'future-model': {
42
+ displayName: 'Future Model',
43
+ quotaInfo: { remainingFraction: 0.42, resetTime: '2027-01-15T08:00:00.000Z' },
44
+ },
45
+ },
46
+ });
47
+ }) as typeof fetch;
48
+ const payload = {
49
+ auth_method: 'oauth-personal',
50
+ token: {
51
+ access_token: 'old-access',
52
+ expiry: '2000-01-01T00:00:00.000Z',
53
+ refresh_token: 'refresh',
54
+ },
55
+ };
56
+ const password = `go-keyring-base64:${Buffer.from(JSON.stringify(payload)).toString('base64')}`;
57
+
58
+ const result = await fetchLimits({
59
+ account: 'antigravity',
60
+ createdAt: '',
61
+ kind: 'Generic Password',
62
+ label: 'gemini',
63
+ password,
64
+ service: 'gemini',
65
+ updatedAt: '',
66
+ });
67
+
68
+ expect(result.quota.ok).toBe(true);
69
+ expect(decodeToken(result.password ?? '')?.token?.access_token).toBe('new-access');
70
+ });
@@ -0,0 +1,199 @@
1
+ import {
2
+ ANTIGRAVITY_VERSION,
3
+ GOOGLE_CLIENT_ID,
4
+ GOOGLE_CLIENT_SECRET,
5
+ GOOGLE_TOKEN_URL,
6
+ LOAD_PROJECT_URL,
7
+ QUOTA_URLS,
8
+ } from '../config.ts';
9
+ import type { LimitResult, Snapshot, TokenPayload } from '../types.ts';
10
+
11
+ type JsonObject = Record<string, unknown>;
12
+ type FetchLimitsResult = {
13
+ password?: string;
14
+ quota: LimitResult;
15
+ };
16
+ type GoogleRefreshResponse = {
17
+ access_token?: string;
18
+ expires_in?: number;
19
+ refresh_token?: string;
20
+ };
21
+
22
+ const REQUEST_TIMEOUT_MS = 15_000;
23
+ const EXPIRY_GRACE_MS = 60_000;
24
+ const TOKEN_PREFIX = 'go-keyring-base64:';
25
+
26
+ const asObject = (value: unknown): JsonObject => {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
28
+ };
29
+
30
+ const stringValue = (value: unknown) => (typeof value === 'string' ? value : undefined);
31
+ const numberValue = (value: unknown) => (typeof value === 'number' ? value : undefined);
32
+
33
+ export const decodeToken = (password: string): TokenPayload | null => {
34
+ try {
35
+ const encoded = password.startsWith(TOKEN_PREFIX) ? password.slice(TOKEN_PREFIX.length) : password;
36
+ return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
37
+ } catch {
38
+ return null;
39
+ }
40
+ };
41
+
42
+ const encodeToken = (password: string, payload: TokenPayload) => {
43
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64');
44
+ return password.startsWith(TOKEN_PREFIX) ? `${TOKEN_PREFIX}${encoded}` : encoded;
45
+ };
46
+
47
+ const headers = (accessToken: string) => {
48
+ const platform = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
49
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch;
50
+ return {
51
+ Authorization: `Bearer ${accessToken}`,
52
+ 'Content-Type': 'application/json',
53
+ 'User-Agent': `antigravity/${ANTIGRAVITY_VERSION} ${platform}/${arch}`,
54
+ };
55
+ };
56
+
57
+ const errorStatus = async (res: Response) => {
58
+ const text = await res.text();
59
+ try {
60
+ const body = asObject(JSON.parse(text));
61
+ const error = asObject(body.error);
62
+ return stringValue(error.status) ?? stringValue(error.message) ?? res.statusText;
63
+ } catch {
64
+ return res.statusText;
65
+ }
66
+ };
67
+
68
+ const postJson = async (url: string, accessToken: string, body: unknown) => {
69
+ const res = await fetch(url, {
70
+ body: JSON.stringify(body),
71
+ headers: headers(accessToken),
72
+ method: 'POST',
73
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
74
+ });
75
+ if (!res.ok) {
76
+ const status = await errorStatus(res);
77
+ throw new Error(`HTTP ${res.status}${status ? `: ${status}` : ''}`);
78
+ }
79
+ return res.json();
80
+ };
81
+
82
+ const refreshAccessToken = async (refreshToken: string) => {
83
+ const clientId = process.env.GOOGLE_CLIENT_ID?.trim() || GOOGLE_CLIENT_ID;
84
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim() || GOOGLE_CLIENT_SECRET;
85
+ if (!clientId || !clientSecret) {
86
+ throw new Error('Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to refresh Antigravity limits');
87
+ }
88
+
89
+ const res = await fetch(GOOGLE_TOKEN_URL, {
90
+ body: new URLSearchParams({
91
+ client_id: clientId,
92
+ client_secret: clientSecret,
93
+ grant_type: 'refresh_token',
94
+ refresh_token: refreshToken,
95
+ }),
96
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
97
+ method: 'POST',
98
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
99
+ });
100
+ if (!res.ok) {
101
+ throw new Error(`Token refresh failed: HTTP ${res.status}`);
102
+ }
103
+ return (await res.json()) as GoogleRefreshResponse;
104
+ };
105
+
106
+ const validAccessToken = async (token: NonNullable<TokenPayload['token']>) => {
107
+ const expiry = token.expiry ? Date.parse(token.expiry) : Number.NaN;
108
+ if (token.access_token && (!token.expiry || Number.isNaN(expiry) || expiry > Date.now() + EXPIRY_GRACE_MS)) {
109
+ return { accessToken: token.access_token };
110
+ }
111
+ if (!token.refresh_token) {
112
+ return { accessToken: token.access_token };
113
+ }
114
+
115
+ const refreshed = await refreshAccessToken(token.refresh_token);
116
+ if (!refreshed.access_token) {
117
+ return { accessToken: undefined };
118
+ }
119
+
120
+ const nextToken = {
121
+ ...token,
122
+ access_token: refreshed.access_token,
123
+ expiry: refreshed.expires_in ? new Date(Date.now() + refreshed.expires_in * 1000).toISOString() : token.expiry,
124
+ refresh_token: refreshed.refresh_token ?? token.refresh_token,
125
+ };
126
+ return { accessToken: refreshed.access_token, token: nextToken };
127
+ };
128
+
129
+ export const fetchLimits = async (snap: Snapshot): Promise<FetchLimitsResult> => {
130
+ const payload = decodeToken(snap.password);
131
+ const token = payload?.token;
132
+ if (!token || (!token.access_token && !token.refresh_token)) {
133
+ return { quota: { error: 'No access token in snapshot', ok: false } };
134
+ }
135
+
136
+ const { accessToken, token: refreshedToken } = await validAccessToken(token);
137
+ if (!accessToken) {
138
+ return { quota: { error: 'Could not refresh access token', ok: false } };
139
+ }
140
+
141
+ const projectData = asObject(
142
+ await postJson(LOAD_PROJECT_URL, accessToken, {
143
+ metadata: { ideType: 'ANTIGRAVITY' },
144
+ }),
145
+ );
146
+ const project = projectData.cloudaicompanionProject;
147
+ const paidTier = asObject(projectData.paidTier);
148
+ const currentTier = asObject(projectData.currentTier);
149
+ const tier =
150
+ stringValue(paidTier.name) ??
151
+ stringValue(paidTier.id) ??
152
+ stringValue(currentTier.name) ??
153
+ stringValue(currentTier.id) ??
154
+ '';
155
+ let lastError = '';
156
+
157
+ for (const url of QUOTA_URLS) {
158
+ try {
159
+ const data = asObject(await postJson(url, accessToken, project ? { project } : {}));
160
+ const responseModels = asObject(data.models);
161
+ const models = Object.fromEntries(
162
+ Object.entries(responseModels)
163
+ .filter(([, info]) => {
164
+ return Boolean(asObject(info).quotaInfo);
165
+ })
166
+ .map(([name, info]) => {
167
+ const model = asObject(info);
168
+ const quotaInfo = asObject(model.quotaInfo);
169
+ return [
170
+ name,
171
+ {
172
+ displayName: stringValue(model.displayName) ?? name,
173
+ percentage: Math.round((numberValue(quotaInfo.remainingFraction) ?? 0) * 100),
174
+ resetTime: stringValue(quotaInfo.resetTime) ?? '',
175
+ },
176
+ ];
177
+ }),
178
+ );
179
+ return {
180
+ password: refreshedToken
181
+ ? encodeToken(snap.password, { ...payload, token: refreshedToken })
182
+ : undefined,
183
+ quota: { expires: refreshedToken?.expiry ?? token.expiry ?? '', models, ok: true, tier },
184
+ };
185
+ } catch (error) {
186
+ lastError = String(error);
187
+ if (!/HTTP (?:429|5\d\d|403)/.test(lastError)) {
188
+ throw error;
189
+ }
190
+ }
191
+ }
192
+
193
+ return {
194
+ quota: {
195
+ error: `Quota API is rate-limited or unavailable${lastError ? ` (${lastError.replace(/^Error:\s*/, '')})` : ''}`,
196
+ ok: false,
197
+ },
198
+ };
199
+ };
@@ -0,0 +1,8 @@
1
+ import { expect, it } from 'bun:test';
2
+ import { parsePassword } from './keychain.ts';
3
+
4
+ it('should parse escaped keychain password output without greedy capture', () => {
5
+ const stderr = '"labl"<blob>="gemini"\npassword: "go-keyring-base64:abc\\"def"\n"extra"<blob>="ignored"';
6
+
7
+ expect(parsePassword(stderr)).toBe('go-keyring-base64:abc"def');
8
+ });