codex-usage-dashboard 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/README.md +113 -0
- package/bin/codex-usage.js +661 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Codex Usage Dashboard
|
|
2
|
+
|
|
3
|
+
Multi-user Codex usage tracking with a Vercel-hosted dashboard, Supabase Auth, and both direct `npx` connect and website pairing flows.
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
|
|
7
|
+
- Vite + TanStack Router + React Query
|
|
8
|
+
- Supabase Auth + Postgres
|
|
9
|
+
- Vercel Functions for pairing and sync ingest
|
|
10
|
+
- Local Codex access through `codex app-server`
|
|
11
|
+
|
|
12
|
+
## What changed
|
|
13
|
+
|
|
14
|
+
This repo no longer treats the dashboard as a public read-only board.
|
|
15
|
+
|
|
16
|
+
- Users can start from the website with Google, or from the terminal with a one-line `npx` command.
|
|
17
|
+
- The direct terminal path provisions a dashboard session, opens the browser, and stores the local sync token under `CODEX_HOME`.
|
|
18
|
+
- The website pairing flow is still available and now emits `npx` commands instead of `curl | node`.
|
|
19
|
+
- Vercel receives sync payloads and writes them to Supabase with the service-role key.
|
|
20
|
+
- Row-level security keeps each signed-in user scoped to their own accounts and snapshots.
|
|
21
|
+
|
|
22
|
+
## Local setup
|
|
23
|
+
|
|
24
|
+
1. Start Docker Desktop.
|
|
25
|
+
2. Run `npm install`.
|
|
26
|
+
3. Run `npm run setup:local`.
|
|
27
|
+
4. Run `supabase db reset`.
|
|
28
|
+
5. Run `npm run dev`.
|
|
29
|
+
6. Open `http://localhost:5173`.
|
|
30
|
+
|
|
31
|
+
The Vite dev server now serves the local pairing and sync API routes under `/api/*`.
|
|
32
|
+
|
|
33
|
+
## Hosted setup
|
|
34
|
+
|
|
35
|
+
1. Link the repo to a hosted Supabase project with `supabase link --project-ref <ref>`.
|
|
36
|
+
2. Run `npm run setup:hosted`.
|
|
37
|
+
3. Set these Vercel environment variables:
|
|
38
|
+
- `SUPABASE_URL`
|
|
39
|
+
- `SUPABASE_SERVICE_ROLE_KEY`
|
|
40
|
+
- `VITE_SUPABASE_URL`
|
|
41
|
+
- `VITE_SUPABASE_ANON_KEY`
|
|
42
|
+
4. In Supabase Auth settings, enable `Manual linking` if you want guest dashboard sessions to upgrade into Google later.
|
|
43
|
+
5. If you want the website-first flow, enable the Google provider in Supabase Auth.
|
|
44
|
+
6. Deploy to Vercel.
|
|
45
|
+
|
|
46
|
+
`npm run setup:hosted` now writes the local env files and pushes any pending hosted Supabase migrations.
|
|
47
|
+
|
|
48
|
+
## Direct connect flow
|
|
49
|
+
|
|
50
|
+
Run this on any machine that already has Codex installed:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npx codex-usage-dashboard@latest connect --site "https://your-site.vercel.app"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That command:
|
|
57
|
+
|
|
58
|
+
- starts a local `codex app-server`
|
|
59
|
+
- reads the current Codex account and rate limits
|
|
60
|
+
- provisions a dashboard owner session without requiring Google first
|
|
61
|
+
- opens the hosted dashboard in the browser
|
|
62
|
+
- stores the device token under `CODEX_HOME` in `codex-usage-sync.json`
|
|
63
|
+
- pushes the first snapshot to the dashboard
|
|
64
|
+
|
|
65
|
+
If the same machine is already connected, rerun the same command to reopen the dashboard and refresh the local snapshot.
|
|
66
|
+
|
|
67
|
+
## Website pairing flow
|
|
68
|
+
|
|
69
|
+
1. Sign in on the website.
|
|
70
|
+
2. Click `Create pairing command`.
|
|
71
|
+
3. Run the generated command on the machine that already has Codex installed.
|
|
72
|
+
|
|
73
|
+
The generated command looks like:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx codex-usage-dashboard@latest pair "https://your-site.vercel.app/api/pair/complete?token=..."
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Live sync after pairing
|
|
80
|
+
|
|
81
|
+
To keep syncing from that machine:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx codex-usage-dashboard@latest sync --watch
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The saved device token is read from the same Codex home that the CLI uses.
|
|
88
|
+
|
|
89
|
+
## Dev scripts
|
|
90
|
+
|
|
91
|
+
- `npm run connect -- --site "<site-url>"`: local version of the direct connect command
|
|
92
|
+
- `npm run pair -- "<pair-url>"`: local version of the hosted pairing command
|
|
93
|
+
- `npm run sync -- --watch`: local version of the hosted sync command
|
|
94
|
+
- `npm run collector`: legacy single-operator collector script
|
|
95
|
+
|
|
96
|
+
## Tario-specific Git workflow for Codex
|
|
97
|
+
|
|
98
|
+
If the current machine or user context indicates Tario is the operator, treat that as a master-by-default environment.
|
|
99
|
+
|
|
100
|
+
- Use the local machine identity to decide this.
|
|
101
|
+
- Treat `scutil --get ComputerName` returning `tario` as the primary signal.
|
|
102
|
+
- Treat `/Users/tarioyou` and the local user `tarioyou` as reinforcing signals.
|
|
103
|
+
- In that Tario context, unless otherwise specified, work directly on `master`.
|
|
104
|
+
- If the user explicitly says to work on a branch, create or switch to that branch and work there.
|
|
105
|
+
- Do not create, switch to, or push feature branches by default.
|
|
106
|
+
- Do not default to PR-first workflows or branch-based workflows unless the user asks for them.
|
|
107
|
+
- This rule is specific to Tario's environment; when the operator is not Tario, normal branch workflows remain allowed.
|
|
108
|
+
|
|
109
|
+
## Notes
|
|
110
|
+
|
|
111
|
+
- The Vercel API routes live in `api/`.
|
|
112
|
+
- Pairing and device state live in `codex_pairing_sessions` and `codex_devices`.
|
|
113
|
+
- Existing rows without an owner remain in the database but are hidden by RLS from signed-in users.
|
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
5
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import process from 'node:process'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_POLL_MS = 60_000
|
|
11
|
+
const CONFIG_FILE_NAME = 'codex-usage-sync.json'
|
|
12
|
+
const NPX_COMMAND = 'npx codex-usage-dashboard@latest'
|
|
13
|
+
|
|
14
|
+
class StdioCodexClient {
|
|
15
|
+
constructor({ codexHome }) {
|
|
16
|
+
this.codexHome = codexHome
|
|
17
|
+
this.child = null
|
|
18
|
+
this.buffer = ''
|
|
19
|
+
this.pending = new Map()
|
|
20
|
+
this.requestId = 0
|
|
21
|
+
this.notificationHandler = null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async connect() {
|
|
25
|
+
if (this.child) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.child = await this.spawnAppServer()
|
|
30
|
+
this.child.stdout.setEncoding('utf8')
|
|
31
|
+
this.child.stdout.on('data', (chunk) => {
|
|
32
|
+
this.consumeStdout(chunk)
|
|
33
|
+
})
|
|
34
|
+
this.child.stderr.on('data', (chunk) => {
|
|
35
|
+
const message = chunk.toString().trim()
|
|
36
|
+
if (message) {
|
|
37
|
+
console.error(`[codex] ${message}`)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
await this.request('initialize', {
|
|
42
|
+
capabilities: {},
|
|
43
|
+
clientInfo: {
|
|
44
|
+
name: 'codex_usage_sync',
|
|
45
|
+
title: 'Codex Usage Sync',
|
|
46
|
+
version: '0.1.0',
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
this.notify('initialized', {})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async close() {
|
|
53
|
+
if (!this.child) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.child.kill('SIGTERM')
|
|
58
|
+
this.child = null
|
|
59
|
+
|
|
60
|
+
for (const pending of this.pending.values()) {
|
|
61
|
+
pending.reject(new Error('Codex app-server closed.'))
|
|
62
|
+
}
|
|
63
|
+
this.pending.clear()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onNotification(handler) {
|
|
67
|
+
this.notificationHandler = handler
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
request(method, params) {
|
|
71
|
+
if (!this.child?.stdin || this.child.stdin.destroyed) {
|
|
72
|
+
throw new Error('Codex app-server is not connected.')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const id = ++this.requestId
|
|
76
|
+
this.child.stdin.write(
|
|
77
|
+
`${JSON.stringify({
|
|
78
|
+
id,
|
|
79
|
+
jsonrpc: '2.0',
|
|
80
|
+
method,
|
|
81
|
+
...(params ? { params } : {}),
|
|
82
|
+
})}\n`,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
this.pending.set(id, { reject, resolve })
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
notify(method, params) {
|
|
91
|
+
if (!this.child?.stdin || this.child.stdin.destroyed) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.child.stdin.write(
|
|
96
|
+
`${JSON.stringify({
|
|
97
|
+
jsonrpc: '2.0',
|
|
98
|
+
method,
|
|
99
|
+
...(params ? { params } : {}),
|
|
100
|
+
})}\n`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
consumeStdout(chunk) {
|
|
105
|
+
this.buffer += chunk
|
|
106
|
+
|
|
107
|
+
while (true) {
|
|
108
|
+
const newlineIndex = this.buffer.indexOf('\n')
|
|
109
|
+
if (newlineIndex === -1) {
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const line = this.buffer.slice(0, newlineIndex).trim()
|
|
114
|
+
this.buffer = this.buffer.slice(newlineIndex + 1)
|
|
115
|
+
|
|
116
|
+
if (!line) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let message
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
message = JSON.parse(line)
|
|
124
|
+
} catch {
|
|
125
|
+
console.error(`[codex] ${line}`)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (typeof message.id === 'number') {
|
|
130
|
+
const pending = this.pending.get(message.id)
|
|
131
|
+
if (!pending) {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.pending.delete(message.id)
|
|
136
|
+
|
|
137
|
+
if (message.error) {
|
|
138
|
+
pending.reject(
|
|
139
|
+
new Error(message.error.message ?? 'Codex app-server error.'),
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
pending.resolve(message.result)
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (message.method && this.notificationHandler) {
|
|
149
|
+
this.notificationHandler(message.method)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
spawnAppServer() {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const child = spawn('codex', ['app-server', '--listen', 'stdio://'], {
|
|
157
|
+
env: {
|
|
158
|
+
...process.env,
|
|
159
|
+
...(this.codexHome ? { CODEX_HOME: this.codexHome } : {}),
|
|
160
|
+
},
|
|
161
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
child.once('error', (error) => {
|
|
165
|
+
reject(
|
|
166
|
+
new Error(
|
|
167
|
+
error.code === 'ENOENT'
|
|
168
|
+
? 'The `codex` command is not installed on this machine.'
|
|
169
|
+
: error.message,
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
child.once('spawn', () => {
|
|
175
|
+
child.on('exit', (code) => {
|
|
176
|
+
if (code !== 0 && code !== null) {
|
|
177
|
+
console.error(`[codex] app-server exited with code ${code}`)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
resolve(child)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function main() {
|
|
187
|
+
const [, , command, ...restArgs] = process.argv
|
|
188
|
+
|
|
189
|
+
if (!command || command === '--help' || command === '-h') {
|
|
190
|
+
printUsage()
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const args = parseArgs(restArgs)
|
|
195
|
+
|
|
196
|
+
if (command === 'pair') {
|
|
197
|
+
await runPairCommand(args)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (command === 'connect') {
|
|
202
|
+
await runConnectCommand(args)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (command === 'sync') {
|
|
207
|
+
await runSyncCommand(args)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
throw new Error(`Unknown command: ${command}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runPairCommand(args) {
|
|
215
|
+
const pairUrl = args.positionals[0]
|
|
216
|
+
if (!pairUrl) {
|
|
217
|
+
throw new Error('Pass the pairing URL from the website.')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const codexHome = resolveCodexHome(args.options['codex-home'])
|
|
221
|
+
const client = new StdioCodexClient({ codexHome })
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await client.connect()
|
|
225
|
+
const snapshot = await readSnapshot(client, true)
|
|
226
|
+
const device = buildDevicePayload(args, codexHome)
|
|
227
|
+
const response = await fetch(pairUrl, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: {
|
|
230
|
+
'Content-Type': 'application/json',
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
accountState: snapshot.accountState,
|
|
234
|
+
device,
|
|
235
|
+
rateLimits: snapshot.rateLimits,
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
const payload = await parseJsonResponse(response)
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
throw new Error(payload.error ?? 'Pairing failed.')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const config = {
|
|
245
|
+
authMode: 'website-paired',
|
|
246
|
+
codexHome,
|
|
247
|
+
deviceId: payload.deviceId,
|
|
248
|
+
deviceToken: payload.deviceToken,
|
|
249
|
+
dashboardOrigin: new URL(pairUrl).origin,
|
|
250
|
+
label: device.label,
|
|
251
|
+
pollMs: payload.pollMs ?? DEFAULT_POLL_MS,
|
|
252
|
+
syncUrl: payload.syncUrl,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await writeConfig(codexHome, config)
|
|
256
|
+
console.log('Pairing complete.')
|
|
257
|
+
console.log(`Config saved to ${resolveConfigPath(codexHome)}`)
|
|
258
|
+
console.log(
|
|
259
|
+
`Next: run \`${NPX_COMMAND} sync --watch\` on this machine for live updates.`,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if (args.options.watch) {
|
|
263
|
+
await runWatchLoop(client, config, args)
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
await client.close()
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function runConnectCommand(args) {
|
|
271
|
+
const codexHome = resolveCodexHome(args.options['codex-home'])
|
|
272
|
+
const existingConfig = await readConfig(codexHome)
|
|
273
|
+
const client = new StdioCodexClient({ codexHome })
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await client.connect()
|
|
277
|
+
|
|
278
|
+
if (existingConfig) {
|
|
279
|
+
const dashboardUrl = await resolveExistingDashboardUrl(existingConfig, args)
|
|
280
|
+
|
|
281
|
+
if (dashboardUrl) {
|
|
282
|
+
await openDashboard(dashboardUrl)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await syncOnce(client, existingConfig, args)
|
|
286
|
+
console.log('Dashboard opened.')
|
|
287
|
+
|
|
288
|
+
if (args.options.watch) {
|
|
289
|
+
await runWatchLoop(client, existingConfig, args)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const siteOrigin = resolveSiteOrigin(args.options.site)
|
|
296
|
+
if (!siteOrigin) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
'Pass --site <url> the first time you run connect, or set CODEX_USAGE_SITE_URL.',
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const snapshot = await readSnapshot(client, true)
|
|
303
|
+
const device = buildDevicePayload(args, codexHome)
|
|
304
|
+
const response = await fetch(new URL('/api/connect/start', siteOrigin), {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: {
|
|
307
|
+
'Content-Type': 'application/json',
|
|
308
|
+
},
|
|
309
|
+
body: JSON.stringify({
|
|
310
|
+
accountState: snapshot.accountState,
|
|
311
|
+
device,
|
|
312
|
+
rateLimits: snapshot.rateLimits,
|
|
313
|
+
}),
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const payload = await parseJsonResponse(response)
|
|
317
|
+
if (!response.ok) {
|
|
318
|
+
throw new Error(payload.error ?? 'Unable to connect this machine.')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const config = {
|
|
322
|
+
authMode: 'guest-link',
|
|
323
|
+
codexHome,
|
|
324
|
+
dashboardOrigin: siteOrigin,
|
|
325
|
+
deviceId: payload.deviceId,
|
|
326
|
+
deviceToken: payload.deviceToken,
|
|
327
|
+
label: device.label,
|
|
328
|
+
pollMs: payload.pollMs ?? DEFAULT_POLL_MS,
|
|
329
|
+
syncUrl: payload.syncUrl,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
await writeConfig(codexHome, config)
|
|
333
|
+
await openDashboard(payload.dashboardUrl)
|
|
334
|
+
console.log('Dashboard opened.')
|
|
335
|
+
console.log(`Config saved to ${resolveConfigPath(codexHome)}`)
|
|
336
|
+
console.log(
|
|
337
|
+
`Next: rerun \`${NPX_COMMAND} connect --site "${siteOrigin}"\` to reopen this dashboard, or \`${NPX_COMMAND} sync --watch\` for live updates only.`,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if (args.options.watch) {
|
|
341
|
+
await runWatchLoop(client, config, args)
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
await client.close()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function runSyncCommand(args) {
|
|
349
|
+
const codexHome = resolveCodexHome(args.options['codex-home'])
|
|
350
|
+
const config = await readConfig(codexHome)
|
|
351
|
+
if (!config) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
'No pairing config found. Run `connect` or pair this machine from the website first.',
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const client = new StdioCodexClient({ codexHome })
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
await client.connect()
|
|
361
|
+
|
|
362
|
+
if (args.options.watch) {
|
|
363
|
+
await runWatchLoop(client, config, args)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await syncOnce(client, config, args)
|
|
368
|
+
console.log('Sync complete.')
|
|
369
|
+
} finally {
|
|
370
|
+
await client.close()
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function runWatchLoop(client, config, args) {
|
|
375
|
+
let scheduledRefresh = null
|
|
376
|
+
let isSyncing = false
|
|
377
|
+
|
|
378
|
+
const run = async () => {
|
|
379
|
+
if (isSyncing) {
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
isSyncing = true
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
await syncOnce(client, config, args)
|
|
387
|
+
console.log(`[${new Date().toLocaleTimeString()}] Sync complete.`)
|
|
388
|
+
} catch (error) {
|
|
389
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
390
|
+
} finally {
|
|
391
|
+
isSyncing = false
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
client.onNotification((method) => {
|
|
396
|
+
if (
|
|
397
|
+
method !== 'account/login/completed' &&
|
|
398
|
+
method !== 'account/rateLimits/updated' &&
|
|
399
|
+
method !== 'account/updated'
|
|
400
|
+
) {
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (scheduledRefresh) {
|
|
405
|
+
clearTimeout(scheduledRefresh)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
scheduledRefresh = setTimeout(() => {
|
|
409
|
+
void run()
|
|
410
|
+
}, 500)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
await run()
|
|
414
|
+
|
|
415
|
+
const interval = setInterval(() => {
|
|
416
|
+
void run()
|
|
417
|
+
}, config.pollMs ?? DEFAULT_POLL_MS)
|
|
418
|
+
|
|
419
|
+
await waitForTermination(async () => {
|
|
420
|
+
clearInterval(interval)
|
|
421
|
+
|
|
422
|
+
if (scheduledRefresh) {
|
|
423
|
+
clearTimeout(scheduledRefresh)
|
|
424
|
+
scheduledRefresh = null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
await client.close()
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function syncOnce(client, config, args) {
|
|
432
|
+
const snapshot = await readSnapshot(client, false)
|
|
433
|
+
if (!snapshot) {
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const response = await fetch(config.syncUrl, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
headers: {
|
|
440
|
+
'Content-Type': 'application/json',
|
|
441
|
+
},
|
|
442
|
+
body: JSON.stringify({
|
|
443
|
+
accountState: snapshot.accountState,
|
|
444
|
+
device: buildDevicePayload(args, config.codexHome, config.label),
|
|
445
|
+
deviceToken: config.deviceToken,
|
|
446
|
+
rateLimits: snapshot.rateLimits,
|
|
447
|
+
}),
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
const payload = await parseJsonResponse(response)
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
throw new Error(payload.error ?? 'Sync failed.')
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function resolveExistingDashboardUrl(config, args) {
|
|
457
|
+
if (config.authMode === 'guest-link') {
|
|
458
|
+
const response = await fetch(new URL('/api/connect/open', config.syncUrl), {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: {
|
|
461
|
+
'Content-Type': 'application/json',
|
|
462
|
+
},
|
|
463
|
+
body: JSON.stringify({
|
|
464
|
+
deviceToken: config.deviceToken,
|
|
465
|
+
}),
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
const payload = await parseJsonResponse(response)
|
|
469
|
+
if (!response.ok) {
|
|
470
|
+
throw new Error(payload.error ?? 'Unable to open the dashboard.')
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return payload.dashboardUrl ?? null
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const siteOrigin =
|
|
477
|
+
resolveSiteOrigin(args.options.site) ??
|
|
478
|
+
config.dashboardOrigin ??
|
|
479
|
+
new URL(config.syncUrl).origin
|
|
480
|
+
|
|
481
|
+
return siteOrigin || null
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function readSnapshot(client, failWhenLoggedOut) {
|
|
485
|
+
const accountState = await client.request('account/read', {
|
|
486
|
+
refreshToken: false,
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
if (!accountState.account) {
|
|
490
|
+
if (failWhenLoggedOut) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
'No logged-in Codex account was found. Run `codex login` and try again.',
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
console.log('No logged-in Codex account found yet. Waiting for login.')
|
|
497
|
+
return null
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const rateLimits = await client.request('account/rateLimits/read')
|
|
501
|
+
return { accountState, rateLimits }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function buildDevicePayload(args, codexHome, fallbackLabel) {
|
|
505
|
+
return {
|
|
506
|
+
codexHome,
|
|
507
|
+
label: args.options.label ?? fallbackLabel ?? os.hostname(),
|
|
508
|
+
machineName: os.hostname(),
|
|
509
|
+
metadata: {
|
|
510
|
+
arch: process.arch,
|
|
511
|
+
node: process.version,
|
|
512
|
+
platform: process.platform,
|
|
513
|
+
},
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function openDashboard(url) {
|
|
518
|
+
try {
|
|
519
|
+
await openInBrowser(url)
|
|
520
|
+
} catch {
|
|
521
|
+
console.log(`Open this URL in your browser: ${url}`)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function parseArgs(rawArgs) {
|
|
526
|
+
const options = {}
|
|
527
|
+
const positionals = []
|
|
528
|
+
|
|
529
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
530
|
+
const value = rawArgs[index]
|
|
531
|
+
|
|
532
|
+
if (!value.startsWith('--')) {
|
|
533
|
+
positionals.push(value)
|
|
534
|
+
continue
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const key = value.slice(2)
|
|
538
|
+
|
|
539
|
+
if (key === 'watch') {
|
|
540
|
+
options.watch = true
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const nextValue = rawArgs[index + 1]
|
|
545
|
+
if (!nextValue || nextValue.startsWith('--')) {
|
|
546
|
+
throw new Error(`Missing value for --${key}`)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
options[key] = nextValue
|
|
550
|
+
index += 1
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return { options, positionals }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function resolveSiteOrigin(configuredValue) {
|
|
557
|
+
const value = configuredValue ?? process.env.CODEX_USAGE_SITE_URL
|
|
558
|
+
if (!value) {
|
|
559
|
+
return null
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return new URL(value).origin
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function resolveCodexHome(configuredValue) {
|
|
566
|
+
const home = configuredValue ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex')
|
|
567
|
+
|
|
568
|
+
if (!home.startsWith('~/')) {
|
|
569
|
+
return home
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return path.join(os.homedir(), home.slice(2))
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function resolveConfigPath(codexHome) {
|
|
576
|
+
return path.join(codexHome, CONFIG_FILE_NAME)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function openInBrowser(url) {
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
const target =
|
|
582
|
+
process.platform === 'darwin'
|
|
583
|
+
? { args: [url], command: 'open' }
|
|
584
|
+
: process.platform === 'win32'
|
|
585
|
+
? { args: ['/c', 'start', '', url], command: 'cmd' }
|
|
586
|
+
: { args: [url], command: 'xdg-open' }
|
|
587
|
+
|
|
588
|
+
const child = spawn(target.command, target.args, {
|
|
589
|
+
detached: true,
|
|
590
|
+
stdio: 'ignore',
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
child.once('error', reject)
|
|
594
|
+
child.once('spawn', () => {
|
|
595
|
+
child.unref()
|
|
596
|
+
resolve()
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function writeConfig(codexHome, config) {
|
|
602
|
+
await mkdir(codexHome, { recursive: true })
|
|
603
|
+
await writeFile(
|
|
604
|
+
resolveConfigPath(codexHome),
|
|
605
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
606
|
+
'utf8',
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function readConfig(codexHome) {
|
|
611
|
+
const configPath = resolveConfigPath(codexHome)
|
|
612
|
+
if (!existsSync(configPath)) {
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return JSON.parse(await readFile(configPath, 'utf8'))
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function parseJsonResponse(response) {
|
|
620
|
+
const payload = await response.json().catch(() => null)
|
|
621
|
+
if (!payload || typeof payload !== 'object') {
|
|
622
|
+
return {}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return payload
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function waitForTermination(cleanup) {
|
|
629
|
+
return new Promise((resolve) => {
|
|
630
|
+
let finished = false
|
|
631
|
+
|
|
632
|
+
const finish = async () => {
|
|
633
|
+
if (finished) {
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
finished = true
|
|
638
|
+
await cleanup()
|
|
639
|
+
resolve()
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
process.once('SIGINT', () => {
|
|
643
|
+
void finish()
|
|
644
|
+
})
|
|
645
|
+
process.once('SIGTERM', () => {
|
|
646
|
+
void finish()
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function printUsage() {
|
|
652
|
+
console.log('Usage:')
|
|
653
|
+
console.log(' codex-usage connect [--site <url>] [--watch] [--codex-home <path>] [--label <name>]')
|
|
654
|
+
console.log(' codex-usage pair <pair-url> [--watch] [--codex-home <path>] [--label <name>]')
|
|
655
|
+
console.log(' codex-usage sync [--watch] [--codex-home <path>] [--label <name>]')
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
await main().catch((error) => {
|
|
659
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
660
|
+
process.exit(1)
|
|
661
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "codex-usage-dashboard",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"files": [
|
|
6
|
+
"README.md",
|
|
7
|
+
"bin"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"codex-usage": "./bin/codex-usage.js",
|
|
12
|
+
"codex-usage-dashboard": "./bin/codex-usage.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "vite",
|
|
16
|
+
"build": "vite build",
|
|
17
|
+
"typecheck": "tsc -b",
|
|
18
|
+
"lint": "eslint .",
|
|
19
|
+
"preview": "vite preview",
|
|
20
|
+
"connect": "node ./bin/codex-usage.js connect",
|
|
21
|
+
"pair": "node ./bin/codex-usage.js pair",
|
|
22
|
+
"sync": "node ./bin/codex-usage.js sync",
|
|
23
|
+
"setup:hosted": "tsx scripts/setup-hosted.ts",
|
|
24
|
+
"setup:local": "tsx scripts/setup-local.ts",
|
|
25
|
+
"collector": "tsx scripts/codex-collector.ts",
|
|
26
|
+
"collector:once": "tsx scripts/codex-collector.ts --once"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@fontsource-variable/geist": "^5.2.8",
|
|
30
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
31
|
+
"@supabase/supabase-js": "^2.101.1",
|
|
32
|
+
"@tanstack/react-query": "^5.96.2",
|
|
33
|
+
"@tanstack/react-query-devtools": "^5.96.2",
|
|
34
|
+
"@tanstack/react-router": "^1.168.10",
|
|
35
|
+
"@tanstack/router-devtools": "^1.166.11",
|
|
36
|
+
"class-variance-authority": "^0.7.1",
|
|
37
|
+
"clsx": "^2.1.1",
|
|
38
|
+
"dotenv": "^17.4.1",
|
|
39
|
+
"lucide-react": "^1.7.0",
|
|
40
|
+
"radix-ui": "^1.4.3",
|
|
41
|
+
"react": "^19.2.4",
|
|
42
|
+
"react-dom": "^19.2.4",
|
|
43
|
+
"shadcn": "^4.1.2",
|
|
44
|
+
"tailwind-merge": "^3.5.0",
|
|
45
|
+
"tw-animate-css": "^1.4.0",
|
|
46
|
+
"zod": "^4.3.6"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@eslint/js": "^9.39.4",
|
|
50
|
+
"@tailwindcss/vite": "^4.2.2",
|
|
51
|
+
"@tanstack/router-plugin": "^1.167.12",
|
|
52
|
+
"@types/node": "^24.12.0",
|
|
53
|
+
"@types/react": "^19.2.14",
|
|
54
|
+
"@types/react-dom": "^19.2.3",
|
|
55
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
56
|
+
"eslint": "^9.39.4",
|
|
57
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
58
|
+
"eslint-plugin-react-refresh": "^0.5.2",
|
|
59
|
+
"globals": "^17.4.0",
|
|
60
|
+
"tailwindcss": "^4.2.2",
|
|
61
|
+
"tsx": "^4.21.0",
|
|
62
|
+
"typescript": "~5.9.3",
|
|
63
|
+
"typescript-eslint": "^8.57.0",
|
|
64
|
+
"vite": "^8.0.1"
|
|
65
|
+
}
|
|
66
|
+
}
|