gemix-dash 0.0.0-stage → 0.0.2
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 +35 -2
- package/bin/gemix-dash.js +139 -0
- package/lib/accounts.js +209 -0
- package/lib/oauth.js +102 -0
- package/lib/paths.js +44 -0
- package/lib/quota.js +46 -0
- package/lib/validation.js +21 -0
- package/package.json +40 -4
- package/public/app.js +181 -0
- package/public/index.html +77 -0
- package/public/style.css +132 -0
- package/server.mjs +135 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aditya Thummar
|
|
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
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# gemix-dash
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Local web dashboard for [gemix](https://www.npmjs.com/package/gemix) accounts (`~/.gemix`).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g gemix-dash
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## CLI usage
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
gemix-dash # start the server and open the dashboard in your browser
|
|
15
|
+
gemix-dash stop # stop the running server
|
|
16
|
+
gemix-dash status # check whether the server is running
|
|
17
|
+
gemix-dash -p 8080 # start on a custom port
|
|
18
|
+
gemix-dash --help # show all options
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
> The dashboard binds to `127.0.0.1` only. A PID file is written to the OS temp directory so `gemix-dash stop` can find and terminate the process.
|
|
22
|
+
|
|
23
|
+
## Development
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm start # http://127.0.0.1:4577 (PORT env to change)
|
|
27
|
+
npm test # smoke test
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Cards per account with real per-model limits (same source as `agy` `/usage`: Cloud Code `fetchAvailableModels`), cached 60s.
|
|
33
|
+
- Add (Google link + paste code), Use (same as `gemix use`), Rename, Remove, all with confirmations.
|
|
34
|
+
- Binds to 127.0.0.1 only, rejects non-loopback Host/Origin, never sends tokens to the browser.
|
|
35
|
+
- `lib/` is copied from gemix's `dist/`; `oauth.js` is split into `buildAuthUrl` / `exchangeCode` for the web flow.
|
|
36
|
+
- Token refreshes are written back to `~/.gemix/accounts/<name>/tokens.json`.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const PID_FILE = path.join(tmpdir(), 'gemix-dash.pid');
|
|
11
|
+
|
|
12
|
+
function readPid() {
|
|
13
|
+
try { return JSON.parse(readFileSync(PID_FILE, 'utf8')); } catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writePid(pid, port) {
|
|
17
|
+
writeFileSync(PID_FILE, JSON.stringify({ pid, port }), 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function removePid() {
|
|
21
|
+
try { rmSync(PID_FILE); } catch { /* already gone */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Open a URL in the default browser (cross-platform). */
|
|
25
|
+
function openBrowser(url) {
|
|
26
|
+
const cmd = process.platform === 'darwin' ? 'open'
|
|
27
|
+
: process.platform === 'win32' ? 'start'
|
|
28
|
+
: 'xdg-open';
|
|
29
|
+
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Check whether a port is already listening. */
|
|
33
|
+
function portInUse(port) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const s = createServer();
|
|
36
|
+
s.once('error', () => resolve(true));
|
|
37
|
+
s.once('listening', () => { s.close(); resolve(false); });
|
|
38
|
+
s.listen(port, '127.0.0.1');
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const args = process.argv.slice(2);
|
|
45
|
+
const cmd = args[0]; // 'stop' | 'status' | undefined
|
|
46
|
+
|
|
47
|
+
const portIndex = args.findIndex((a) => a === '-p' || a === '--port');
|
|
48
|
+
const cliPort = portIndex !== -1 ? Number(args[portIndex + 1]) : undefined;
|
|
49
|
+
|
|
50
|
+
if (args.includes('-h') || args.includes('--help')) {
|
|
51
|
+
console.log(`
|
|
52
|
+
Usage: gemix-dash [stop|status] [-p <port>]
|
|
53
|
+
|
|
54
|
+
Local web dashboard for gemix accounts (~/.gemix)
|
|
55
|
+
|
|
56
|
+
Commands:
|
|
57
|
+
(none) Start the server and open the dashboard in your browser
|
|
58
|
+
stop Stop a running gemix-dash server
|
|
59
|
+
status Show whether gemix-dash is running
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
-p, --port <number> Port to listen on (default: 4577 or PORT env)
|
|
63
|
+
-h, --help Show this help message
|
|
64
|
+
`);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── stop ──────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
if (cmd === 'stop') {
|
|
71
|
+
const info = readPid();
|
|
72
|
+
if (!info) {
|
|
73
|
+
console.log('gemix-dash is not running.');
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
process.kill(info.pid, 'SIGTERM');
|
|
78
|
+
removePid();
|
|
79
|
+
console.log(`gemix-dash (pid ${info.pid}) stopped.`);
|
|
80
|
+
} catch {
|
|
81
|
+
console.log(`Process ${info.pid} not found – cleaning up stale PID file.`);
|
|
82
|
+
removePid();
|
|
83
|
+
}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── status ────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
if (cmd === 'status') {
|
|
90
|
+
const info = readPid();
|
|
91
|
+
if (!info) {
|
|
92
|
+
console.log('gemix-dash is not running.');
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
process.kill(info.pid, 0); // throws if process is gone
|
|
97
|
+
console.log(`gemix-dash is running pid=${info.pid} http://127.0.0.1:${info.port}`);
|
|
98
|
+
} catch {
|
|
99
|
+
console.log('gemix-dash is not running (stale PID file removed).');
|
|
100
|
+
removePid();
|
|
101
|
+
}
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── start ─────────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
// Dynamic import so we only load the server when we actually start.
|
|
108
|
+
const { server, PORT: DEFAULT_PORT } = await import('../server.mjs');
|
|
109
|
+
const port = cliPort || Number(process.env.PORT || DEFAULT_PORT);
|
|
110
|
+
|
|
111
|
+
// Check if already running on this port
|
|
112
|
+
if (await portInUse(port)) {
|
|
113
|
+
// If there's a PID file for this port, just open the browser
|
|
114
|
+
const info = readPid();
|
|
115
|
+
if (info && info.port === port) {
|
|
116
|
+
console.log(`gemix-dash is already running at http://127.0.0.1:${port}`);
|
|
117
|
+
openBrowser(`http://127.0.0.1:${port}`);
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
console.error(`Error: port ${port} is already in use by another process.`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
server.listen(port, '127.0.0.1', () => {
|
|
125
|
+
const url = `http://127.0.0.1:${port}`;
|
|
126
|
+
console.log(`gemix-dash running at ${url}`);
|
|
127
|
+
console.log(`Run 'gemix-dash stop' to shut it down.`);
|
|
128
|
+
writePid(process.pid, port);
|
|
129
|
+
openBrowser(url);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Clean up PID file on exit
|
|
133
|
+
function cleanup() {
|
|
134
|
+
const info = readPid();
|
|
135
|
+
if (info && info.pid === process.pid) removePid();
|
|
136
|
+
}
|
|
137
|
+
process.on('exit', cleanup);
|
|
138
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
139
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
package/lib/accounts.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { getAccountsFilePath, getActiveFilePath, getAccountTokensPath, getAccountDir, ensureGemxDir, ensureAccountDir, getGeminiOAuthCredsPath, getGeminiGoogleAccountsPath, getAntigravityOAuthTokenPath, } from './paths.js';
|
|
4
|
+
import { refreshTokens } from './oauth.js';
|
|
5
|
+
const DEFAULT_LIMITS = {
|
|
6
|
+
requestsPerDay: 500,
|
|
7
|
+
tokensPerDay: 1000000,
|
|
8
|
+
};
|
|
9
|
+
export async function loadAccounts() {
|
|
10
|
+
await ensureGemxDir();
|
|
11
|
+
const filePath = getAccountsFilePath();
|
|
12
|
+
try {
|
|
13
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
14
|
+
return JSON.parse(content);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { accounts: {}, active: null };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function saveAccounts(data) {
|
|
21
|
+
await ensureGemxDir();
|
|
22
|
+
const filePath = getAccountsFilePath();
|
|
23
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
24
|
+
}
|
|
25
|
+
export async function loadTokens(username) {
|
|
26
|
+
const tokensPath = getAccountTokensPath(username);
|
|
27
|
+
try {
|
|
28
|
+
const content = await fs.readFile(tokensPath, 'utf-8');
|
|
29
|
+
return JSON.parse(content);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function saveTokens(username, tokens) {
|
|
36
|
+
await ensureAccountDir(username);
|
|
37
|
+
const tokensPath = getAccountTokensPath(username);
|
|
38
|
+
await fs.writeFile(tokensPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
export async function loadGoogleAccounts() {
|
|
41
|
+
const filePath = getGeminiGoogleAccountsPath();
|
|
42
|
+
try {
|
|
43
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
44
|
+
return JSON.parse(content);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return { active: '', old: [] };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function saveGoogleAccounts(data) {
|
|
51
|
+
const filePath = getGeminiGoogleAccountsPath();
|
|
52
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
53
|
+
await fs.mkdir(dir, { recursive: true });
|
|
54
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
55
|
+
}
|
|
56
|
+
export async function updateGoogleAccountsActive(email) {
|
|
57
|
+
const data = await loadGoogleAccounts();
|
|
58
|
+
if (data.active && data.active !== email) {
|
|
59
|
+
data.old = [...(data.old || []), data.active];
|
|
60
|
+
}
|
|
61
|
+
data.active = email;
|
|
62
|
+
await saveGoogleAccounts(data);
|
|
63
|
+
}
|
|
64
|
+
export async function copyTokensToAntigravity(tokens) {
|
|
65
|
+
try {
|
|
66
|
+
const antigravityPath = getAntigravityOAuthTokenPath();
|
|
67
|
+
const dir = antigravityPath.substring(0, antigravityPath.lastIndexOf('/'));
|
|
68
|
+
await fs.mkdir(dir, { recursive: true });
|
|
69
|
+
const antigravityData = {
|
|
70
|
+
token: {
|
|
71
|
+
access_token: tokens.access_token,
|
|
72
|
+
token_type: tokens.token_type,
|
|
73
|
+
refresh_token: tokens.refresh_token,
|
|
74
|
+
expiry: new Date(tokens.expiry_date).toISOString(),
|
|
75
|
+
},
|
|
76
|
+
auth_method: 'consumer',
|
|
77
|
+
};
|
|
78
|
+
await fs.writeFile(antigravityPath, JSON.stringify(antigravityData, null, 2), { mode: 0o600 });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export async function copyTokensToKeychain(tokens) {
|
|
86
|
+
try {
|
|
87
|
+
const keychainData = {
|
|
88
|
+
token: {
|
|
89
|
+
access_token: tokens.access_token,
|
|
90
|
+
token_type: tokens.token_type,
|
|
91
|
+
refresh_token: tokens.refresh_token,
|
|
92
|
+
expiry: new Date(tokens.expiry_date).toISOString(),
|
|
93
|
+
},
|
|
94
|
+
auth_method: 'consumer',
|
|
95
|
+
};
|
|
96
|
+
const password = `go-keyring-base64:${Buffer.from(JSON.stringify(keychainData)).toString('base64')}`;
|
|
97
|
+
try {
|
|
98
|
+
execSync(`security delete-generic-password -s "gemini" -a "antigravity" 2>/dev/null`, { stdio: 'ignore' });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Ignore if entry doesn't exist
|
|
102
|
+
}
|
|
103
|
+
execSync(`security add-generic-password -s "gemini" -a "antigravity" -w "${password}" -U`, { stdio: 'ignore' });
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export async function getActiveUsername() {
|
|
111
|
+
const activePath = getActiveFilePath();
|
|
112
|
+
try {
|
|
113
|
+
return await fs.readFile(activePath, 'utf-8');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function setActiveUsername(username) {
|
|
120
|
+
await ensureGemxDir();
|
|
121
|
+
const activePath = getActiveFilePath();
|
|
122
|
+
await fs.writeFile(activePath, username, { mode: 0o600 });
|
|
123
|
+
}
|
|
124
|
+
export async function copyTokensToGemini(username) {
|
|
125
|
+
const tokens = await loadTokens(username);
|
|
126
|
+
if (!tokens)
|
|
127
|
+
return false;
|
|
128
|
+
const geminiPath = getGeminiOAuthCredsPath();
|
|
129
|
+
const geminiDir = geminiPath.substring(0, geminiPath.lastIndexOf('/'));
|
|
130
|
+
await fs.mkdir(geminiDir, { recursive: true });
|
|
131
|
+
await fs.writeFile(geminiPath, JSON.stringify(buildGeminiCredsObject(tokens), null, 2), { mode: 0o600 });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
export async function addAccount(username, email, name, picture, tokens, limits = DEFAULT_LIMITS) {
|
|
135
|
+
const accounts = await loadAccounts();
|
|
136
|
+
accounts.accounts[username] = {
|
|
137
|
+
email,
|
|
138
|
+
name,
|
|
139
|
+
picture,
|
|
140
|
+
limits,
|
|
141
|
+
created: new Date().toISOString(),
|
|
142
|
+
lastUsed: null,
|
|
143
|
+
};
|
|
144
|
+
await saveTokens(username, tokens);
|
|
145
|
+
await saveAccounts(accounts);
|
|
146
|
+
if (!accounts.active) {
|
|
147
|
+
await setActiveUsername(username);
|
|
148
|
+
accounts.active = username;
|
|
149
|
+
await saveAccounts(accounts);
|
|
150
|
+
await updateGoogleAccountsActive(email);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export async function removeAccount(username) {
|
|
154
|
+
const accounts = await loadAccounts();
|
|
155
|
+
if (!accounts.accounts[username])
|
|
156
|
+
return false;
|
|
157
|
+
delete accounts.accounts[username];
|
|
158
|
+
if (accounts.active === username) {
|
|
159
|
+
const remaining = Object.keys(accounts.accounts);
|
|
160
|
+
accounts.active = remaining.length > 0 ? remaining[0] : null;
|
|
161
|
+
}
|
|
162
|
+
await fs.rm(getAccountDir(username), { recursive: true, force: true });
|
|
163
|
+
await saveAccounts(accounts);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
export function buildGeminiCredsObject(tokens) {
|
|
167
|
+
const creds = {
|
|
168
|
+
access_token: tokens.access_token,
|
|
169
|
+
refresh_token: tokens.refresh_token,
|
|
170
|
+
scope: tokens.scope,
|
|
171
|
+
token_type: tokens.token_type,
|
|
172
|
+
expiry_date: tokens.expiry_date,
|
|
173
|
+
};
|
|
174
|
+
if (tokens.id_token) {
|
|
175
|
+
creds.id_token = tokens.id_token;
|
|
176
|
+
}
|
|
177
|
+
return creds;
|
|
178
|
+
}
|
|
179
|
+
export async function switchAccount(username) {
|
|
180
|
+
const accounts = await loadAccounts();
|
|
181
|
+
if (!accounts.accounts[username])
|
|
182
|
+
return false;
|
|
183
|
+
let tokens = await loadTokens(username);
|
|
184
|
+
if (!tokens)
|
|
185
|
+
return false;
|
|
186
|
+
// Refresh tokens if expired
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const fiveMinutes = 5 * 60 * 1000;
|
|
189
|
+
if (tokens.expiry_date && tokens.expiry_date - now < fiveMinutes) {
|
|
190
|
+
const refreshed = await refreshTokens(tokens);
|
|
191
|
+
if (refreshed) {
|
|
192
|
+
tokens = refreshed;
|
|
193
|
+
await saveTokens(username, tokens);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
await fs.mkdir(getGeminiOAuthCredsPath().substring(0, getGeminiOAuthCredsPath().lastIndexOf('/')), { recursive: true });
|
|
197
|
+
await fs.writeFile(getGeminiOAuthCredsPath(), JSON.stringify(buildGeminiCredsObject(tokens), null, 2), { mode: 0o600 });
|
|
198
|
+
await copyTokensToAntigravity(tokens);
|
|
199
|
+
await copyTokensToKeychain(tokens);
|
|
200
|
+
await updateGoogleAccountsActive(accounts.accounts[username].email);
|
|
201
|
+
accounts.active = username;
|
|
202
|
+
accounts.accounts[username].lastUsed = new Date().toISOString();
|
|
203
|
+
await saveAccounts(accounts);
|
|
204
|
+
await setActiveUsername(username);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
export function validateUsername(username) {
|
|
208
|
+
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{1,28}[a-zA-Z0-9])?$/.test(username);
|
|
209
|
+
}
|
package/lib/oauth.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
|
3
|
+
const OAUTH_CLIENT_SECRET = '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';
|
|
4
|
+
const OAUTH_SCOPE = [
|
|
5
|
+
'openid',
|
|
6
|
+
'https://www.googleapis.com/auth/cloud-platform',
|
|
7
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
8
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
9
|
+
'https://www.googleapis.com/auth/cclog',
|
|
10
|
+
'https://www.googleapis.com/auth/experimentsandconfigs',
|
|
11
|
+
];
|
|
12
|
+
const REDIRECT_URI = 'https://antigravity.google/oauth-callback';
|
|
13
|
+
function generateCodeVerifier() {
|
|
14
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
15
|
+
}
|
|
16
|
+
function generateCodeChallenge(verifier) {
|
|
17
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
18
|
+
}
|
|
19
|
+
// Split of gemix's interactive authenticateWithGoogle: build URL now, exchange pasted code later.
|
|
20
|
+
export function buildAuthUrl() {
|
|
21
|
+
const state = crypto.randomBytes(32).toString('hex');
|
|
22
|
+
const codeVerifier = generateCodeVerifier();
|
|
23
|
+
const params = new URLSearchParams({
|
|
24
|
+
client_id: OAUTH_CLIENT_ID,
|
|
25
|
+
response_type: 'code',
|
|
26
|
+
redirect_uri: REDIRECT_URI,
|
|
27
|
+
scope: OAUTH_SCOPE.join(' '),
|
|
28
|
+
access_type: 'offline',
|
|
29
|
+
prompt: 'consent',
|
|
30
|
+
state,
|
|
31
|
+
code_challenge: generateCodeChallenge(codeVerifier),
|
|
32
|
+
code_challenge_method: 'S256',
|
|
33
|
+
});
|
|
34
|
+
return { url: `https://accounts.google.com/o/oauth2/auth?${params}`, codeVerifier };
|
|
35
|
+
}
|
|
36
|
+
export async function exchangeCode(code, codeVerifier) {
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
|
41
|
+
body: new URLSearchParams({
|
|
42
|
+
client_id: OAUTH_CLIENT_ID,
|
|
43
|
+
client_secret: OAUTH_CLIENT_SECRET,
|
|
44
|
+
code,
|
|
45
|
+
grant_type: 'authorization_code',
|
|
46
|
+
redirect_uri: REDIRECT_URI,
|
|
47
|
+
code_verifier: codeVerifier,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
if (!tokenResponse.ok) {
|
|
51
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status} ${await tokenResponse.text()}`);
|
|
52
|
+
}
|
|
53
|
+
const t = await tokenResponse.json();
|
|
54
|
+
const userInfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
55
|
+
headers: { Authorization: `Bearer ${t.access_token}` },
|
|
56
|
+
});
|
|
57
|
+
if (!userInfoResponse.ok) throw new Error(`Failed to fetch user info: ${userInfoResponse.status}`);
|
|
58
|
+
const u = await userInfoResponse.json();
|
|
59
|
+
return {
|
|
60
|
+
tokens: {
|
|
61
|
+
access_token: t.access_token,
|
|
62
|
+
refresh_token: t.refresh_token,
|
|
63
|
+
id_token: '',
|
|
64
|
+
scope: t.scope || OAUTH_SCOPE.join(' '),
|
|
65
|
+
token_type: t.token_type || 'Bearer',
|
|
66
|
+
expiry_date: startTime + t.expires_in * 1000,
|
|
67
|
+
},
|
|
68
|
+
email: u.email,
|
|
69
|
+
name: u.name,
|
|
70
|
+
picture: u.picture,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function refreshTokens(tokens) {
|
|
74
|
+
try {
|
|
75
|
+
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
79
|
+
},
|
|
80
|
+
body: new URLSearchParams({
|
|
81
|
+
client_id: OAUTH_CLIENT_ID,
|
|
82
|
+
client_secret: OAUTH_CLIENT_SECRET,
|
|
83
|
+
refresh_token: tokens.refresh_token,
|
|
84
|
+
grant_type: 'refresh_token',
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
if (!tokenResponse.ok)
|
|
88
|
+
return null;
|
|
89
|
+
const tokenPayload = (await tokenResponse.json());
|
|
90
|
+
return {
|
|
91
|
+
access_token: tokenPayload.access_token,
|
|
92
|
+
refresh_token: tokens.refresh_token,
|
|
93
|
+
id_token: tokens.id_token || '',
|
|
94
|
+
scope: tokens.scope,
|
|
95
|
+
token_type: tokenPayload.token_type || 'Bearer',
|
|
96
|
+
expiry_date: Date.now() + tokenPayload.expires_in * 1000,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
const GEMX_DIR = '.gemix';
|
|
5
|
+
const GEMINI_DIR = '.gemini';
|
|
6
|
+
const ACCOUNTS_FILE = 'accounts.json';
|
|
7
|
+
const ACTIVE_FILE = 'active';
|
|
8
|
+
const ACCOUNTS_DIR = 'accounts';
|
|
9
|
+
const OAUTH_CREDS_FILE = 'oauth_creds.json';
|
|
10
|
+
const GOOGLE_ACCOUNTS_FILE = 'google_accounts.json';
|
|
11
|
+
export function getGemxDir() {
|
|
12
|
+
return path.join(os.homedir(), GEMX_DIR);
|
|
13
|
+
}
|
|
14
|
+
export function getAccountsFilePath() {
|
|
15
|
+
return path.join(getGemxDir(), ACCOUNTS_FILE);
|
|
16
|
+
}
|
|
17
|
+
export function getActiveFilePath() {
|
|
18
|
+
return path.join(getGemxDir(), ACTIVE_FILE);
|
|
19
|
+
}
|
|
20
|
+
export function getAccountsDir() {
|
|
21
|
+
return path.join(getGemxDir(), ACCOUNTS_DIR);
|
|
22
|
+
}
|
|
23
|
+
export function getAccountDir(username) {
|
|
24
|
+
return path.join(getAccountsDir(), username);
|
|
25
|
+
}
|
|
26
|
+
export function getAccountTokensPath(username) {
|
|
27
|
+
return path.join(getAccountDir(username), 'tokens.json');
|
|
28
|
+
}
|
|
29
|
+
export function getGeminiOAuthCredsPath() {
|
|
30
|
+
return path.join(os.homedir(), GEMINI_DIR, OAUTH_CREDS_FILE);
|
|
31
|
+
}
|
|
32
|
+
export function getGeminiGoogleAccountsPath() {
|
|
33
|
+
return path.join(os.homedir(), GEMINI_DIR, GOOGLE_ACCOUNTS_FILE);
|
|
34
|
+
}
|
|
35
|
+
export function getAntigravityOAuthTokenPath() {
|
|
36
|
+
return path.join(os.homedir(), GEMINI_DIR, 'antigravity-cli', 'antigravity-oauth-token');
|
|
37
|
+
}
|
|
38
|
+
export async function ensureGemxDir() {
|
|
39
|
+
await fs.mkdir(getGemxDir(), { recursive: true });
|
|
40
|
+
await fs.mkdir(getAccountsDir(), { recursive: true });
|
|
41
|
+
}
|
|
42
|
+
export async function ensureAccountDir(username) {
|
|
43
|
+
await fs.mkdir(getAccountDir(username), { recursive: true });
|
|
44
|
+
}
|
package/lib/quota.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Same data agy's /usage shows: Cloud Code retrieveUserQuota -> weekly buckets per model.
|
|
2
|
+
// (fetchAvailableModels.quotaInfo is only the short ~5h window, so it does not match /usage.)
|
|
3
|
+
import { loadTokens, saveTokens } from './accounts.js';
|
|
4
|
+
import { refreshTokens } from './oauth.js';
|
|
5
|
+
|
|
6
|
+
const HOST = 'https://daily-cloudcode-pa.googleapis.com/v1internal:';
|
|
7
|
+
|
|
8
|
+
async function call(method, token, body) {
|
|
9
|
+
const r = await fetch(HOST + method, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': 'antigravity' },
|
|
12
|
+
body: JSON.stringify(body),
|
|
13
|
+
});
|
|
14
|
+
if (!r.ok) throw new Error(`${method} ${r.status}`);
|
|
15
|
+
return r.json();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Returns a usable access token, refreshing (and persisting) if near expiry. null = dead login.
|
|
19
|
+
export async function freshToken(username) {
|
|
20
|
+
let t = await loadTokens(username);
|
|
21
|
+
if (!t) return null;
|
|
22
|
+
if (!t.expiry_date || t.expiry_date - Date.now() < 5 * 60 * 1000) {
|
|
23
|
+
const n = await refreshTokens(t);
|
|
24
|
+
if (!n) return null;
|
|
25
|
+
t = n;
|
|
26
|
+
await saveTokens(username, t);
|
|
27
|
+
}
|
|
28
|
+
return t;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function fetchQuota(username) {
|
|
32
|
+
const t = await freshToken(username);
|
|
33
|
+
if (!t) return { status: 'relogin' };
|
|
34
|
+
try {
|
|
35
|
+
const a = await call('loadCodeAssist', t.access_token, {
|
|
36
|
+
metadata: { ideType: 'ANTIGRAVITY', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' },
|
|
37
|
+
});
|
|
38
|
+
const q = await call('retrieveUserQuota', t.access_token, { project: a.cloudaicompanionProject });
|
|
39
|
+
const models = (q.buckets || [])
|
|
40
|
+
.filter((b) => /^(gemini|claude|gpt)/i.test(b.modelId)) // skips chat_*/tab_* internal buckets
|
|
41
|
+
.map((b) => ({ id: b.modelId, remaining: b.remainingFraction ?? 0, resetTime: b.resetTime || null }));
|
|
42
|
+
return { status: 'ok', tier: a.currentTier?.id || null, tokenExpiry: t.expiry_date, models };
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return { status: 'error', error: e.message, tokenExpiry: t.expiry_date };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function validateUsername(username) {
|
|
2
|
+
if (!username || username.length < 3) {
|
|
3
|
+
return { valid: false, error: 'Username must be at least 3 characters' };
|
|
4
|
+
}
|
|
5
|
+
if (username.length > 30) {
|
|
6
|
+
return { valid: false, error: 'Username must be at most 30 characters' };
|
|
7
|
+
}
|
|
8
|
+
if (!/^[a-zA-Z0-9]/.test(username)) {
|
|
9
|
+
return { valid: false, error: 'Username must start with a letter or number' };
|
|
10
|
+
}
|
|
11
|
+
if (!/[a-zA-Z0-9]$/.test(username)) {
|
|
12
|
+
return { valid: false, error: 'Username must end with a letter or number' };
|
|
13
|
+
}
|
|
14
|
+
if (!/^[a-zA-Z0-9-]+$/.test(username)) {
|
|
15
|
+
return { valid: false, error: 'Username can only contain letters, numbers, and hyphens' };
|
|
16
|
+
}
|
|
17
|
+
if (username.includes('--')) {
|
|
18
|
+
return { valid: false, error: 'Username cannot contain consecutive hyphens' };
|
|
19
|
+
}
|
|
20
|
+
return { valid: true };
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gemix-dash",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Local web dashboard for gemix accounts (~/.gemix)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"gemix-dash": "./bin/gemix-dash.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"lib",
|
|
13
|
+
"public",
|
|
14
|
+
"server.mjs"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node server.mjs",
|
|
18
|
+
"test": "node test/smoke.mjs",
|
|
19
|
+
"prepublishOnly": "npm test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"gemix",
|
|
23
|
+
"gemini",
|
|
24
|
+
"dashboard",
|
|
25
|
+
"google-accounts",
|
|
26
|
+
"cli",
|
|
27
|
+
"webui"
|
|
28
|
+
],
|
|
29
|
+
"author": "Aditya Thummar",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/AdityaThummar/gemix-dash.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/AdityaThummar/gemix-dash/issues"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/AdityaThummar/gemix-dash#readme",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18.0.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const $ = (s) => document.querySelector(s);
|
|
2
|
+
const grid = $('#grid');
|
|
3
|
+
let authSession = null;
|
|
4
|
+
|
|
5
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
6
|
+
|
|
7
|
+
async function api(method, url, body) {
|
|
8
|
+
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: body && JSON.stringify(body) });
|
|
9
|
+
const j = await r.json().catch(() => ({}));
|
|
10
|
+
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
11
|
+
return j;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toast(msg, err) {
|
|
15
|
+
const t = document.createElement('div');
|
|
16
|
+
t.className = 'toast' + (err ? ' err' : '');
|
|
17
|
+
t.textContent = msg;
|
|
18
|
+
$('#toasts').append(t);
|
|
19
|
+
setTimeout(() => t.remove(), 4000);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function confirmDialog(title, body, okLabel, destructive) {
|
|
23
|
+
$('#c-title').textContent = title;
|
|
24
|
+
$('#c-body').textContent = body;
|
|
25
|
+
const ok = $('#c-ok');
|
|
26
|
+
ok.textContent = okLabel;
|
|
27
|
+
ok.className = 'btn ' + (destructive ? 'destructive' : 'default');
|
|
28
|
+
const d = $('#dlg-confirm');
|
|
29
|
+
d.showModal();
|
|
30
|
+
return new Promise((res) => {
|
|
31
|
+
ok.onclick = () => { d.close(); res(true); };
|
|
32
|
+
d.onclose = () => res(false);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fmtReset = (iso) => {
|
|
37
|
+
if (!iso) return '';
|
|
38
|
+
const ms = new Date(iso) - Date.now();
|
|
39
|
+
if (ms <= 0) return 'Resets now';
|
|
40
|
+
const t = Math.ceil(ms / 6e4), h = Math.floor(t / 60), m = t % 60;
|
|
41
|
+
return 'Resets in ' + (h >= 24 ? `${Math.floor(h / 24)}d ${h % 24}h` : h ? `${h}h ${m}m` : `${m}m`);
|
|
42
|
+
};
|
|
43
|
+
const barColor = (f) => (f > 0.5 ? 'var(--ok)' : f > 0.2 ? 'var(--warn)' : 'var(--destructive)');
|
|
44
|
+
|
|
45
|
+
const SEGS = 24;
|
|
46
|
+
function modelRow(m) {
|
|
47
|
+
const pct = Math.round(m.remaining * 100);
|
|
48
|
+
const on = Math.ceil(m.remaining * SEGS);
|
|
49
|
+
const cells = Array.from({ length: SEGS }, (_, i) => `<i${i < on ? ` style="background:${barColor(m.remaining)}"` : ''}></i>`).join('');
|
|
50
|
+
return `<div class="model" title="${esc(m.id)}">
|
|
51
|
+
<span class="lbl">${esc(m.name)}</span>
|
|
52
|
+
<span class="pct">${pct}<small>%</small></span>
|
|
53
|
+
<div class="seg">${cells}</div>
|
|
54
|
+
<span class="reset">${fmtReset(m.resetTime) || ' '}</span></div>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function health(q) {
|
|
58
|
+
if (q.status === 'relogin') return '<span class="badge bad">Re-login needed</span>';
|
|
59
|
+
if (q.status === 'error') return `<span class="badge warn" title="${esc(q.error)}">Quota unavailable</span>`;
|
|
60
|
+
return '<span class="badge ok">Token OK</span>';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function card(a) {
|
|
64
|
+
const q = a.quota;
|
|
65
|
+
// Two pools: Gemini vs third-party (Claude, GPT-OSS). Show the tightest model of each.
|
|
66
|
+
const pool = (test) => (q.models || []).filter((m) => test(m.id)).sort((x, y) => x.remaining - y.remaining)[0];
|
|
67
|
+
const rows = [
|
|
68
|
+
['Gemini', pool((n) => /^gemini/i.test(n))],
|
|
69
|
+
['Claude & GPT', pool((n) => !/^gemini/i.test(n))],
|
|
70
|
+
].filter(([, m]) => m).map(([label, m]) => modelRow({ ...m, name: label }));
|
|
71
|
+
const body = q.status === 'ok' ? rows.join('') || '<span class="muted">No quota data</span>'
|
|
72
|
+
: q.status === 'relogin' ? '<span class="muted">Remove and add this account again.</span>'
|
|
73
|
+
: '<span class="muted">Could not load limits. Try Refresh.</span>';
|
|
74
|
+
return `<article class="card${a.active ? ' active' : ''}" data-u="${esc(a.username)}">
|
|
75
|
+
<div class="card-head">
|
|
76
|
+
<img class="avatar" src="${esc(a.picture)}" alt="" referrerpolicy="no-referrer">
|
|
77
|
+
<div class="who"><b>${esc(a.username)}</b><span>${esc(a.email)}</span></div>
|
|
78
|
+
${a.active ? '<span class="badge primary">Active</span>' : ''}
|
|
79
|
+
</div>
|
|
80
|
+
<div class="badges">${q.tier ? `<span class="badge">${esc(q.tier)}</span>` : ''}${health(q)}</div>
|
|
81
|
+
<div class="card-body">${body}</div>
|
|
82
|
+
<div class="card-foot">
|
|
83
|
+
<button class="btn ghost sm" data-act="rename">Rename</button>
|
|
84
|
+
<button class="btn ghost sm danger" data-act="remove">Remove</button>
|
|
85
|
+
<span class="grow"></span>
|
|
86
|
+
${a.active ? '<span class="in-use">In use</span>' : '<button class="btn outline sm use" data-act="switch">Use account</button>'}
|
|
87
|
+
</div></article>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function load(force) {
|
|
91
|
+
if (!grid.children.length) grid.innerHTML = '<div class="skeleton"></div>'.repeat(3);
|
|
92
|
+
try {
|
|
93
|
+
const { accounts } = await api('GET', '/api/accounts' + (force ? '?refresh=1' : ''));
|
|
94
|
+
document.getElementById('summary').textContent = `${accounts.length} account${accounts.length === 1 ? '' : 's'}`;
|
|
95
|
+
grid.innerHTML = accounts.length ? accounts.map(card).join('') : '<div class="empty">No accounts yet. Click “Add account”.</div>';
|
|
96
|
+
} catch (e) { toast(e.message, true); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
grid.addEventListener('click', async (e) => {
|
|
100
|
+
const b = e.target.closest('[data-act]');
|
|
101
|
+
if (!b) return;
|
|
102
|
+
const u = b.closest('.card').dataset.u;
|
|
103
|
+
const act = b.dataset.act;
|
|
104
|
+
try {
|
|
105
|
+
if (act === 'switch') {
|
|
106
|
+
if (!(await confirmDialog(`Switch to ${u}?`, 'Replaces the login used by Gemini CLI and Antigravity. Running sessions may need a restart.', 'Switch'))) return;
|
|
107
|
+
await api('POST', `/api/accounts/${encodeURIComponent(u)}/switch`);
|
|
108
|
+
toast(`Switched to ${u}`);
|
|
109
|
+
}
|
|
110
|
+
if (act === 'remove') {
|
|
111
|
+
if (!(await confirmDialog(`Remove ${u}?`, 'Deletes the saved login from gemix. You can add it again later.', 'Remove', true))) return;
|
|
112
|
+
await api('DELETE', `/api/accounts/${encodeURIComponent(u)}`);
|
|
113
|
+
toast(`Removed ${u}`);
|
|
114
|
+
}
|
|
115
|
+
if (act === 'rename') {
|
|
116
|
+
$('#rn-name').value = u;
|
|
117
|
+
const d = $('#dlg-rename');
|
|
118
|
+
d.showModal();
|
|
119
|
+
$('#rn-ok').onclick = async () => {
|
|
120
|
+
try {
|
|
121
|
+
await api('PATCH', `/api/accounts/${encodeURIComponent(u)}`, { name: $('#rn-name').value.trim() });
|
|
122
|
+
d.close(); toast('Renamed'); // Theme: Auto follows the OS, Light/Dark pin it (remembered per browser).
|
|
123
|
+
function setTheme(t) {
|
|
124
|
+
if (t === 'system') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t;
|
|
125
|
+
try { t === 'system' ? localStorage.removeItem('theme') : localStorage.setItem('theme', t); } catch {}
|
|
126
|
+
document.querySelectorAll('[data-theme-set]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themeSet === t)));
|
|
127
|
+
}
|
|
128
|
+
document.querySelectorAll('[data-theme-set]').forEach((b) => (b.onclick = () => setTheme(b.dataset.themeSet)));
|
|
129
|
+
setTheme(document.documentElement.dataset.theme || 'system');
|
|
130
|
+
|
|
131
|
+
load();
|
|
132
|
+
} catch (err) { toast(err.message, true); }
|
|
133
|
+
};
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
load(true);
|
|
137
|
+
} catch (err) { toast(err.message, true); }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
document.addEventListener('click', (e) => { if (e.target.matches('[data-close]')) e.target.closest('dialog').close(); });
|
|
141
|
+
$('#refresh').onclick = () => load(true);
|
|
142
|
+
|
|
143
|
+
// Add flow: name -> link -> paste code
|
|
144
|
+
$('#add').onclick = () => {
|
|
145
|
+
authSession = null;
|
|
146
|
+
$('#add-name').value = ''; $('#add-code').value = '';
|
|
147
|
+
$('#add-link').classList.add('hidden');
|
|
148
|
+
$('#add-go').textContent = 'Get login link';
|
|
149
|
+
$('#dlg-add').showModal();
|
|
150
|
+
};
|
|
151
|
+
$('#copy-url').onclick = () => navigator.clipboard.writeText($('#add-url').value).then(() => toast('Link copied'));
|
|
152
|
+
$('#open-url').onclick = () => window.open($('#add-url').value, '_blank', 'noopener');
|
|
153
|
+
$('#add-go').onclick = async () => {
|
|
154
|
+
const name = $('#add-name').value.trim();
|
|
155
|
+
const btn = $('#add-go');
|
|
156
|
+
btn.disabled = true;
|
|
157
|
+
try {
|
|
158
|
+
if (!authSession) {
|
|
159
|
+
if (!name) throw new Error('Enter a name');
|
|
160
|
+
authSession = await api('POST', '/api/auth-url');
|
|
161
|
+
$('#add-url').value = authSession.url;
|
|
162
|
+
$('#add-link').classList.remove('hidden');
|
|
163
|
+
btn.textContent = 'Register';
|
|
164
|
+
} else {
|
|
165
|
+
await api('POST', '/api/accounts', { name, code: $('#add-code').value, id: authSession.id });
|
|
166
|
+
$('#dlg-add').close(); toast(`Added ${name}`); load(true);
|
|
167
|
+
}
|
|
168
|
+
} catch (e) { toast(e.message, true); }
|
|
169
|
+
btn.disabled = false;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Theme: Auto follows the OS, Light/Dark pin it (remembered per browser).
|
|
173
|
+
function setTheme(t) {
|
|
174
|
+
if (t === 'system') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t;
|
|
175
|
+
try { t === 'system' ? localStorage.removeItem('theme') : localStorage.setItem('theme', t); } catch {}
|
|
176
|
+
document.querySelectorAll('[data-theme-set]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.themeSet === t)));
|
|
177
|
+
}
|
|
178
|
+
document.querySelectorAll('[data-theme-set]').forEach((b) => (b.onclick = () => setTheme(b.dataset.themeSet)));
|
|
179
|
+
setTheme(document.documentElement.dataset.theme || 'system');
|
|
180
|
+
|
|
181
|
+
load();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>gemix dashboard</title>
|
|
7
|
+
<script>try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch {}</script>
|
|
8
|
+
<link rel="stylesheet" href="style.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="topbar"><div class="wrap">
|
|
12
|
+
<span class="wordmark">gemix</span>
|
|
13
|
+
<div class="right">
|
|
14
|
+
<span class="muted mono">local · 127.0.0.1</span>
|
|
15
|
+
<div class="theme-toggle" role="group" aria-label="Theme">
|
|
16
|
+
<button data-theme-set="system">Auto</button><button data-theme-set="light">Light</button><button data-theme-set="dark">Dark</button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</div></header>
|
|
20
|
+
<div class="wrap">
|
|
21
|
+
<section class="pagehead">
|
|
22
|
+
<div>
|
|
23
|
+
<h1>Accounts</h1>
|
|
24
|
+
<p class="muted">Weekly limits per Google login. Same numbers as <code>agy /usage</code>.</p>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="actions">
|
|
27
|
+
<span class="muted mono" id="summary"></span>
|
|
28
|
+
<button class="btn outline" id="refresh">Refresh limits</button>
|
|
29
|
+
<button class="btn default" id="add">Add account</button>
|
|
30
|
+
</div>
|
|
31
|
+
</section>
|
|
32
|
+
<main id="grid" class="grid"></main>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<dialog id="dlg-add" class="dialog">
|
|
36
|
+
<h2>Add account</h2>
|
|
37
|
+
<p class="muted">Name it, sign in with Google, then paste the code shown after login.</p>
|
|
38
|
+
<label class="label" for="add-name">Name</label>
|
|
39
|
+
<input class="input" id="add-name" placeholder="e.g. work" autocomplete="off">
|
|
40
|
+
<div id="add-link" class="hidden">
|
|
41
|
+
<label class="label">Login link</label>
|
|
42
|
+
<div class="row">
|
|
43
|
+
<input class="input mono" id="add-url" readonly>
|
|
44
|
+
<button class="btn outline" id="copy-url">Copy</button>
|
|
45
|
+
<button class="btn outline" id="open-url">Open</button>
|
|
46
|
+
</div>
|
|
47
|
+
<label class="label" for="add-code">Authorization code</label>
|
|
48
|
+
<input class="input mono" id="add-code" placeholder="Paste code here" autocomplete="off">
|
|
49
|
+
</div>
|
|
50
|
+
<div class="footer">
|
|
51
|
+
<button class="btn ghost" data-close>Cancel</button>
|
|
52
|
+
<button class="btn default" id="add-go">Get login link</button>
|
|
53
|
+
</div>
|
|
54
|
+
</dialog>
|
|
55
|
+
|
|
56
|
+
<dialog id="dlg-confirm" class="dialog">
|
|
57
|
+
<h2 id="c-title"></h2>
|
|
58
|
+
<p class="muted" id="c-body"></p>
|
|
59
|
+
<div class="footer">
|
|
60
|
+
<button class="btn ghost" data-close>Cancel</button>
|
|
61
|
+
<button class="btn default" id="c-ok">Confirm</button>
|
|
62
|
+
</div>
|
|
63
|
+
</dialog>
|
|
64
|
+
|
|
65
|
+
<dialog id="dlg-rename" class="dialog">
|
|
66
|
+
<h2>Rename account</h2>
|
|
67
|
+
<input class="input" id="rn-name" autocomplete="off">
|
|
68
|
+
<div class="footer">
|
|
69
|
+
<button class="btn ghost" data-close>Cancel</button>
|
|
70
|
+
<button class="btn default" id="rn-ok">Save</button>
|
|
71
|
+
</div>
|
|
72
|
+
</dialog>
|
|
73
|
+
|
|
74
|
+
<div id="toasts" class="toasts"></div>
|
|
75
|
+
<script src="app.js"></script>
|
|
76
|
+
</body>
|
|
77
|
+
</html>
|
package/public/style.css
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/* Square corners, roomy spacing. Colors = Gemini app's Material 3 palette, dark + light. */
|
|
2
|
+
:root, :root[data-theme="dark"] {
|
|
3
|
+
color-scheme: dark;
|
|
4
|
+
--background: #131314; --card: #1b1c1d; --panel: #232425;
|
|
5
|
+
--foreground: #e3e3e3; --muted-foreground: #a8abae;
|
|
6
|
+
--border: #2f3133; --border-strong: #444746; --track: #303234;
|
|
7
|
+
--primary: #a8c7fa; --primary-hover: #c2d7fb; --on-primary: #062e6f;
|
|
8
|
+
--destructive: #f2b8b5; --ok: #6dd58c; --warn: #fdd663;
|
|
9
|
+
--gemini: linear-gradient(90deg, #4285f4, #9b72cb, #d96570);
|
|
10
|
+
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
11
|
+
}
|
|
12
|
+
:root[data-theme="light"] {
|
|
13
|
+
color-scheme: light;
|
|
14
|
+
--background: #f8fafd; --card: #ffffff; --panel: #f0f4f9;
|
|
15
|
+
--foreground: #1f1f1f; --muted-foreground: #5f6368;
|
|
16
|
+
--border: #e1e5ea; --border-strong: #c4c7c5; --track: #e3e8ef;
|
|
17
|
+
--primary: #0b57d0; --primary-hover: #0842a0; --on-primary: #ffffff;
|
|
18
|
+
--destructive: #b3261e; --ok: #146c2e; --warn: #8a5a00;
|
|
19
|
+
}
|
|
20
|
+
@media (prefers-color-scheme: light) {
|
|
21
|
+
:root:not([data-theme]) {
|
|
22
|
+
color-scheme: light;
|
|
23
|
+
--background: #f8fafd; --card: #ffffff; --panel: #f0f4f9;
|
|
24
|
+
--foreground: #1f1f1f; --muted-foreground: #5f6368;
|
|
25
|
+
--border: #e1e5ea; --border-strong: #c4c7c5; --track: #e3e8ef;
|
|
26
|
+
--primary: #0b57d0; --primary-hover: #0842a0; --on-primary: #ffffff;
|
|
27
|
+
--destructive: #b3261e; --ok: #146c2e; --warn: #8a5a00;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
* { box-sizing: border-box; border-radius: 0; }
|
|
31
|
+
body { margin: 0; background: var(--background); color: var(--foreground);
|
|
32
|
+
font: 14px/1.55 Inter, system-ui, -apple-system, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
33
|
+
.wrap { max-width: 1240px; margin: 0 auto; padding: 0 32px; }
|
|
34
|
+
.muted { color: var(--muted-foreground); }
|
|
35
|
+
.mono { font-family: var(--mono); font-size: 12px; }
|
|
36
|
+
.hidden { display: none; }
|
|
37
|
+
code { font-family: var(--mono); font-size: 12px; padding: 1px 6px; background: var(--panel); border: 1px solid var(--border); }
|
|
38
|
+
|
|
39
|
+
.topbar { border-bottom: 1px solid var(--border); }
|
|
40
|
+
.topbar .wrap { display: flex; justify-content: space-between; align-items: center; height: 60px; }
|
|
41
|
+
.wordmark { display: flex; align-items: center; gap: 10px; font: 700 15px var(--mono); letter-spacing: .18em; text-transform: uppercase; }
|
|
42
|
+
.wordmark::before { content: ""; width: 14px; height: 14px; background: var(--gemini); }
|
|
43
|
+
|
|
44
|
+
.pagehead { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; flex-wrap: wrap; padding: 48px 0 28px; }
|
|
45
|
+
.pagehead h1 { margin: 0 0 6px; font-size: 30px; font-weight: 650; letter-spacing: -.02em; }
|
|
46
|
+
.pagehead p { margin: 0; }
|
|
47
|
+
.actions { display: flex; align-items: center; gap: 10px; }
|
|
48
|
+
.actions .mono { margin-right: 8px; }
|
|
49
|
+
|
|
50
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px; padding-bottom: 64px; }
|
|
51
|
+
|
|
52
|
+
.card { position: relative; display: flex; flex-direction: column; background: var(--card); border: 1px solid var(--border);
|
|
53
|
+
transition: border-color .15s, box-shadow .15s; }
|
|
54
|
+
.card:hover { border-color: var(--border-strong); }
|
|
55
|
+
.card.active { border-color: color-mix(in srgb, var(--primary) 55%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent); }
|
|
56
|
+
.card.active::before { content: ""; position: absolute; left: -1px; right: -1px; top: -1px; height: 3px; background: var(--gemini); }
|
|
57
|
+
.card-head { display: flex; gap: 14px; align-items: center; padding: 24px 24px 0; }
|
|
58
|
+
.avatar { width: 44px; height: 44px; flex: none; object-fit: cover; background: var(--panel); }
|
|
59
|
+
.who { min-width: 0; flex: 1; }
|
|
60
|
+
.who b { display: block; font-size: 16px; font-weight: 600; }
|
|
61
|
+
.who span { display: block; font-size: 13px; color: var(--muted-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
62
|
+
|
|
63
|
+
.badges { display: flex; gap: 6px; flex-wrap: wrap; padding: 14px 24px 0; }
|
|
64
|
+
.badge { font: 600 10px var(--mono); letter-spacing: .08em; text-transform: uppercase; padding: 4px 8px;
|
|
65
|
+
border: 1px solid var(--border-strong); color: var(--muted-foreground); }
|
|
66
|
+
.badge.primary { align-self: flex-start; background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
|
|
67
|
+
.badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: color-mix(in srgb, var(--ok) 8%, transparent); }
|
|
68
|
+
.badge.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: color-mix(in srgb, var(--warn) 8%, transparent); }
|
|
69
|
+
.badge.bad { color: var(--destructive); border-color: color-mix(in srgb, var(--destructive) 35%, transparent); background: color-mix(in srgb, var(--destructive) 8%, transparent); }
|
|
70
|
+
|
|
71
|
+
.card-body { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 20px 24px 24px; }
|
|
72
|
+
.card-body > .muted { grid-column: 1 / -1; padding: 14px; background: var(--panel); border: 1px solid var(--border); }
|
|
73
|
+
.model { display: grid; grid-template-columns: 1fr auto; align-items: baseline; row-gap: 10px;
|
|
74
|
+
padding: 14px; background: var(--panel); border: 1px solid var(--border); }
|
|
75
|
+
.lbl { font: 600 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted-foreground); }
|
|
76
|
+
.pct { font: 700 22px/1 var(--mono); }
|
|
77
|
+
.pct small { font-size: 11px; color: var(--muted-foreground); margin-left: 1px; }
|
|
78
|
+
.seg { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(24, 1fr); gap: 2px; height: 8px; }
|
|
79
|
+
.seg i { background: var(--track); }
|
|
80
|
+
.reset { grid-column: 1 / -1; font-size: 12px; color: var(--muted-foreground); }
|
|
81
|
+
|
|
82
|
+
.card-foot { display: flex; align-items: center; gap: 6px; padding: 14px 24px; border-top: 1px solid var(--border); }
|
|
83
|
+
.card-foot .grow { flex: 1; }
|
|
84
|
+
.in-use { font: 600 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--primary); padding: 0 4px; }
|
|
85
|
+
|
|
86
|
+
.btn { height: 38px; padding: 0 16px; border: 1px solid var(--border-strong); background: transparent; color: var(--foreground);
|
|
87
|
+
font: 500 13px Inter, system-ui, sans-serif; cursor: pointer; transition: background .12s, border-color .12s, color .12s; }
|
|
88
|
+
.btn:hover:not(:disabled) { background: var(--panel); border-color: var(--muted-foreground); }
|
|
89
|
+
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
90
|
+
.btn.default { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
|
|
91
|
+
.btn.default:hover:not(:disabled) { background: var(--primary-hover); border-color: var(--primary-hover); }
|
|
92
|
+
.btn.destructive { background: var(--destructive); border-color: var(--destructive); color: var(--on-primary); }
|
|
93
|
+
.btn.ghost { border-color: transparent; color: var(--muted-foreground); }
|
|
94
|
+
.btn.ghost:hover:not(:disabled) { color: var(--foreground); background: var(--panel); border-color: transparent; }
|
|
95
|
+
.btn.ghost.danger:hover:not(:disabled) { color: var(--destructive); }
|
|
96
|
+
.btn.sm { height: 34px; padding: 0 12px; }
|
|
97
|
+
.btn:focus-visible, .input:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
98
|
+
|
|
99
|
+
.skeleton { height: 290px; background: linear-gradient(90deg, var(--card), var(--panel), var(--card)); background-size: 200% 100%;
|
|
100
|
+
border: 1px solid var(--border); animation: sk 1.4s infinite; }
|
|
101
|
+
@keyframes sk { to { background-position: -200% 0; } }
|
|
102
|
+
.empty { grid-column: 1 / -1; text-align: center; padding: 80px 20px; color: var(--muted-foreground); border: 1px dashed var(--border-strong); }
|
|
103
|
+
|
|
104
|
+
.dialog { background: var(--card); color: var(--foreground); border: 1px solid var(--border-strong); padding: 28px; width: min(500px, 92vw); }
|
|
105
|
+
.dialog::backdrop { background: rgb(0 0 0 / .6); backdrop-filter: blur(2px); }
|
|
106
|
+
.dialog h2 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
|
107
|
+
.dialog > p { margin: 0; font-size: 13px; }
|
|
108
|
+
.label { display: block; margin: 18px 0 8px; font: 600 11px var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--muted-foreground); }
|
|
109
|
+
.input { width: 100%; height: 40px; padding: 0 12px; border: 1px solid var(--border-strong); background: var(--background); color: var(--foreground); font: inherit; }
|
|
110
|
+
.input:focus { border-color: var(--primary); outline: none; }
|
|
111
|
+
.input.mono { font: 12px var(--mono); }
|
|
112
|
+
.row { display: flex; gap: 8px; } .row .input { flex: 1; min-width: 0; }
|
|
113
|
+
.footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 28px; }
|
|
114
|
+
|
|
115
|
+
.toasts { position: fixed; right: 24px; bottom: 24px; display: flex; flex-direction: column; gap: 8px; }
|
|
116
|
+
.toast { background: var(--panel); border: 1px solid var(--border-strong); border-left: 3px solid var(--primary);
|
|
117
|
+
padding: 12px 16px; max-width: 360px; font-size: 13px; }
|
|
118
|
+
.toast.err { border-left-color: var(--destructive); }
|
|
119
|
+
|
|
120
|
+
@media (max-width: 640px) {
|
|
121
|
+
.wrap { padding: 0 16px; }
|
|
122
|
+
.grid { grid-template-columns: 1fr; }
|
|
123
|
+
.card-body { grid-template-columns: 1fr; }
|
|
124
|
+
}
|
|
125
|
+
.btn.use { color: var(--primary); border-color: color-mix(in srgb, var(--primary) 45%, transparent); }
|
|
126
|
+
.btn.use:hover:not(:disabled) { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
|
|
127
|
+
.theme-toggle { display: flex; border: 1px solid var(--border-strong); }
|
|
128
|
+
.theme-toggle button { height: 30px; padding: 0 10px; border: 0; background: transparent; color: var(--muted-foreground);
|
|
129
|
+
font: 600 10px var(--mono); letter-spacing: .08em; text-transform: uppercase; cursor: pointer; }
|
|
130
|
+
.theme-toggle button + button { border-left: 1px solid var(--border-strong); }
|
|
131
|
+
.theme-toggle button[aria-pressed="true"] { background: var(--panel); color: var(--foreground); }
|
|
132
|
+
.topbar .right { display: flex; align-items: center; gap: 16px; }
|
package/server.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
loadAccounts, saveAccounts, addAccount, removeAccount, switchAccount,
|
|
7
|
+
loadTokens, getActiveUsername, setActiveUsername,
|
|
8
|
+
} from './lib/accounts.js';
|
|
9
|
+
import { getAccountDir } from './lib/paths.js';
|
|
10
|
+
import { buildAuthUrl, exchangeCode } from './lib/oauth.js';
|
|
11
|
+
import { fetchQuota } from './lib/quota.js';
|
|
12
|
+
import { validateUsername } from './lib/validation.js';
|
|
13
|
+
|
|
14
|
+
export const PORT = Number(process.env.PORT || 4577);
|
|
15
|
+
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
|
|
16
|
+
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
|
|
17
|
+
const CACHE_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
const quotaCache = new Map(); // username -> { at, data }
|
|
20
|
+
const pending = new Map(); // auth session id -> { codeVerifier, at }
|
|
21
|
+
|
|
22
|
+
const send = (res, code, body) => {
|
|
23
|
+
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
24
|
+
res.end(JSON.stringify(body));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Block DNS-rebinding / cross-site calls: only loopback Host, and Origin (if sent) must match.
|
|
28
|
+
const hostOk = (req) => {
|
|
29
|
+
const h = (req.headers.host || '').replace(/:\d+$/, '');
|
|
30
|
+
if (h !== '127.0.0.1' && h !== 'localhost') return false;
|
|
31
|
+
const o = req.headers.origin;
|
|
32
|
+
return !o || o === `http://${req.headers.host}`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const readJson = async (req) => {
|
|
36
|
+
let s = '';
|
|
37
|
+
for await (const c of req) { s += c; if (s.length > 1e5) throw new Error('too large'); }
|
|
38
|
+
return s ? JSON.parse(s) : {};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function quotaFor(name, force) {
|
|
42
|
+
const hit = quotaCache.get(name);
|
|
43
|
+
if (!force && hit && Date.now() - hit.at < CACHE_MS) return hit.data;
|
|
44
|
+
const data = await fetchQuota(name);
|
|
45
|
+
quotaCache.set(name, { at: Date.now(), data });
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function listAccounts(force) {
|
|
50
|
+
const { accounts } = await loadAccounts();
|
|
51
|
+
const active = (await getActiveUsername())?.trim() || null;
|
|
52
|
+
const out = await Promise.all(Object.entries(accounts).map(async ([username, a]) => ({
|
|
53
|
+
username, email: a.email, name: a.name, picture: a.picture,
|
|
54
|
+
created: a.created, lastUsed: a.lastUsed, active: username === active,
|
|
55
|
+
quota: await quotaFor(username, force),
|
|
56
|
+
})));
|
|
57
|
+
return { active, accounts: out };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function rename(oldName, newName) {
|
|
61
|
+
const v = validateUsername(newName);
|
|
62
|
+
if (!v.valid) throw Object.assign(new Error(v.error), { code: 400 });
|
|
63
|
+
const data = await loadAccounts();
|
|
64
|
+
if (!data.accounts[oldName]) throw Object.assign(new Error('not found'), { code: 404 });
|
|
65
|
+
if (data.accounts[newName]) throw Object.assign(new Error('name taken'), { code: 409 });
|
|
66
|
+
await fs.rename(getAccountDir(oldName), getAccountDir(newName));
|
|
67
|
+
data.accounts[newName] = data.accounts[oldName];
|
|
68
|
+
delete data.accounts[oldName];
|
|
69
|
+
if (data.active === oldName) data.active = newName;
|
|
70
|
+
await saveAccounts(data);
|
|
71
|
+
if ((await getActiveUsername())?.trim() === oldName) await setActiveUsername(newName);
|
|
72
|
+
quotaCache.delete(oldName);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function api(req, res, url) {
|
|
76
|
+
const m = req.method;
|
|
77
|
+
const seg = url.pathname.split('/').filter(Boolean); // ['api','accounts',name,action]
|
|
78
|
+
const name = seg[2] && decodeURIComponent(seg[2]);
|
|
79
|
+
|
|
80
|
+
if (m === 'GET' && url.pathname === '/api/accounts') {
|
|
81
|
+
return send(res, 200, await listAccounts(url.searchParams.get('refresh') === '1'));
|
|
82
|
+
}
|
|
83
|
+
if (m === 'POST' && url.pathname === '/api/auth-url') {
|
|
84
|
+
const { url: authUrl, codeVerifier } = buildAuthUrl();
|
|
85
|
+
const id = crypto.randomUUID();
|
|
86
|
+
for (const [k, v] of pending) if (Date.now() - v.at > 10 * 60_000) pending.delete(k);
|
|
87
|
+
pending.set(id, { codeVerifier, at: Date.now() });
|
|
88
|
+
return send(res, 200, { id, url: authUrl });
|
|
89
|
+
}
|
|
90
|
+
if (m === 'POST' && url.pathname === '/api/accounts') {
|
|
91
|
+
const { name: n, code, id } = await readJson(req);
|
|
92
|
+
const v = validateUsername(n);
|
|
93
|
+
if (!v.valid) return send(res, 400, { error: v.error });
|
|
94
|
+
if ((await loadAccounts()).accounts[n]) return send(res, 409, { error: `'${n}' already exists` });
|
|
95
|
+
const sess = pending.get(id);
|
|
96
|
+
if (!sess || !code?.trim()) return send(res, 400, { error: 'Auth session expired, reopen the link' });
|
|
97
|
+
const r = await exchangeCode(code.trim(), sess.codeVerifier);
|
|
98
|
+
pending.delete(id);
|
|
99
|
+
await addAccount(n, r.email, r.name, r.picture, r.tokens);
|
|
100
|
+
return send(res, 200, { ok: true, email: r.email });
|
|
101
|
+
}
|
|
102
|
+
if (name && m === 'POST' && seg[3] === 'switch') {
|
|
103
|
+
return (await switchAccount(name)) ? send(res, 200, { ok: true }) : send(res, 404, { error: 'not found or no tokens' });
|
|
104
|
+
}
|
|
105
|
+
if (name && m === 'DELETE' && !seg[3]) {
|
|
106
|
+
quotaCache.delete(name);
|
|
107
|
+
return (await removeAccount(name)) ? send(res, 200, { ok: true }) : send(res, 404, { error: 'not found' });
|
|
108
|
+
}
|
|
109
|
+
if (name && m === 'PATCH' && !seg[3]) {
|
|
110
|
+
await rename(name, (await readJson(req)).name);
|
|
111
|
+
return send(res, 200, { ok: true });
|
|
112
|
+
}
|
|
113
|
+
send(res, 404, { error: 'not found' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const server = http.createServer(async (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
if (!hostOk(req)) return send(res, 403, { error: 'forbidden' });
|
|
119
|
+
const url = new URL(req.url, 'http://x');
|
|
120
|
+
if (url.pathname.startsWith('/api/')) return await api(req, res, url);
|
|
121
|
+
const file = path.join(PUBLIC, url.pathname === '/' ? 'index.html' : url.pathname);
|
|
122
|
+
if (!file.startsWith(PUBLIC + path.sep)) return send(res, 403, { error: 'forbidden' });
|
|
123
|
+
const body = await fs.readFile(file).catch(() => null);
|
|
124
|
+
if (!body) return send(res, 404, { error: 'not found' });
|
|
125
|
+
res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream' });
|
|
126
|
+
res.end(body);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
send(res, e.code >= 400 && e.code < 500 ? e.code : 500, { error: e.message });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
133
|
+
if (isMain) {
|
|
134
|
+
server.listen(PORT, '127.0.0.1', () => console.log(`gemix-dash on http://127.0.0.1:${PORT}`));
|
|
135
|
+
}
|