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 +21 -0
- package/README.md +162 -0
- package/icon.png +0 -0
- package/icon.svg +69 -0
- package/package.json +58 -0
- package/src/antigravity/google.test.ts +70 -0
- package/src/antigravity/google.ts +199 -0
- package/src/antigravity/keychain.test.ts +8 -0
- package/src/antigravity/keychain.ts +84 -0
- package/src/antigravity/service.ts +111 -0
- package/src/codex/service.ts +136 -0
- package/src/codex/usage.test.ts +96 -0
- package/src/codex/usage.ts +219 -0
- package/src/config.ts +55 -0
- package/src/errors.test.ts +20 -0
- package/src/errors.ts +42 -0
- package/src/package-ui-smoke.test.ts +140 -0
- package/src/server.test.ts +63 -0
- package/src/server.ts +289 -0
- package/src/shell.test.ts +8 -0
- package/src/shell.ts +49 -0
- package/src/storage/crypto.ts +24 -0
- package/src/storage/file.ts +23 -0
- package/src/storage/secret.ts +50 -0
- package/src/storage/vault.test.ts +72 -0
- package/src/storage/vault.ts +130 -0
- package/src/types.ts +54 -0
- package/src/ui/client.tsx +392 -0
- package/src/ui/html.ts +15 -0
- package/src/ui/styles.css +213 -0
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
|
+
[](https://www.npmjs.com/package/dondo)
|
|
8
|
+
[](https://bun.sh)
|
|
9
|
+
[](https://www.typescriptlang.org)
|
|
10
|
+
[](https://preactjs.com)
|
|
11
|
+
[](https://biomejs.dev)
|
|
12
|
+
[](https://www.apple.com/macos)
|
|
13
|
+
[](https://antigravity.google)
|
|
14
|
+
[](https://openai.com/codex)
|
|
15
|
+
[](./LICENSE)
|
|
16
|
+
[](https://github.com/ragaeeb/dondo/issues)
|
|
17
|
+
[](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
|
+
});
|