codex-usage-dashboard 0.1.0 → 0.1.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/README.md CHANGED
@@ -7,7 +7,7 @@ Multi-user Codex usage tracking with a Vercel-hosted dashboard, Supabase Auth, a
7
7
  - Vite + TanStack Router + React Query
8
8
  - Supabase Auth + Postgres
9
9
  - Vercel Functions for pairing and sync ingest
10
- - Local Codex access through `codex app-server`
10
+ - Local Codex access through a bundled `@openai/codex` `app-server`
11
11
 
12
12
  ## What changed
13
13
 
@@ -47,12 +47,20 @@ The Vite dev server now serves the local pairing and sync API routes under `/api
47
47
 
48
48
  ## Direct connect flow
49
49
 
50
- Run this on any machine that already has Codex installed:
50
+ Run this on any machine:
51
51
 
52
52
  ```bash
53
53
  npx codex-usage-dashboard@latest connect --site "https://your-site.vercel.app"
54
54
  ```
55
55
 
56
+ The package now brings a compatible `@openai/codex` CLI dependency with it, so `connect`, `pair`, and `sync` do not require a separate global Codex install.
57
+
58
+ If the machine has never logged into Codex before, authenticate once with:
59
+
60
+ ```bash
61
+ npx @openai/codex@latest login
62
+ ```
63
+
56
64
  That command:
57
65
 
58
66
  - starts a local `codex app-server`
@@ -68,7 +76,7 @@ If the same machine is already connected, rerun the same command to reopen the d
68
76
 
69
77
  1. Sign in on the website.
70
78
  2. Click `Create pairing command`.
71
- 3. Run the generated command on the machine that already has Codex installed.
79
+ 3. Run the generated command on the machine you want to pair.
72
80
 
73
81
  The generated command looks like:
74
82
 
@@ -3,19 +3,28 @@
3
3
  import { spawn } from 'node:child_process'
4
4
  import { existsSync } from 'node:fs'
5
5
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
6
+ import { createRequire } from 'node:module'
6
7
  import os from 'node:os'
7
8
  import path from 'node:path'
8
9
  import process from 'node:process'
9
10
 
10
11
  const DEFAULT_POLL_MS = 60_000
12
+ const CODEX_HELP_TIMEOUT_MS = 15_000
11
13
  const CONFIG_FILE_NAME = 'codex-usage-sync.json'
12
14
  const NPX_COMMAND = 'npx codex-usage-dashboard@latest'
13
15
 
16
+ const require = createRequire(import.meta.url)
17
+
18
+ let codexAppServerSupportPromise = null
19
+ let resolvedCodexExecutable = null
20
+
14
21
  class StdioCodexClient {
15
22
  constructor({ codexHome }) {
16
23
  this.codexHome = codexHome
17
24
  this.child = null
18
25
  this.buffer = ''
26
+ this.isClosing = false
27
+ this.lastStderrMessage = ''
19
28
  this.pending = new Map()
20
29
  this.requestId = 0
21
30
  this.notificationHandler = null
@@ -26,17 +35,9 @@ class StdioCodexClient {
26
35
  return
27
36
  }
28
37
 
38
+ this.buffer = ''
39
+ this.lastStderrMessage = ''
29
40
  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
 
41
42
  await this.request('initialize', {
42
43
  capabilities: {},
@@ -54,13 +55,12 @@ class StdioCodexClient {
54
55
  return
55
56
  }
56
57
 
57
- this.child.kill('SIGTERM')
58
+ const child = this.child
58
59
  this.child = null
60
+ this.isClosing = true
61
+ child.kill('SIGTERM')
59
62
 
60
- for (const pending of this.pending.values()) {
61
- pending.reject(new Error('Codex app-server closed.'))
62
- }
63
- this.pending.clear()
63
+ this.rejectPending(new Error('Codex app-server closed.'))
64
64
  }
65
65
 
66
66
  onNotification(handler) {
@@ -73,17 +73,25 @@ class StdioCodexClient {
73
73
  }
74
74
 
75
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
76
  return new Promise((resolve, reject) => {
86
77
  this.pending.set(id, { reject, resolve })
78
+
79
+ this.child.stdin.write(
80
+ `${JSON.stringify({
81
+ id,
82
+ jsonrpc: '2.0',
83
+ method,
84
+ ...(params ? { params } : {}),
85
+ })}\n`,
86
+ (error) => {
87
+ if (!error) {
88
+ return
89
+ }
90
+
91
+ this.pending.delete(id)
92
+ reject(error)
93
+ },
94
+ )
87
95
  })
88
96
  }
89
97
 
@@ -151,32 +159,78 @@ class StdioCodexClient {
151
159
  }
152
160
  }
153
161
 
154
- spawnAppServer() {
162
+ rejectPending(error) {
163
+ for (const pending of this.pending.values()) {
164
+ pending.reject(error)
165
+ }
166
+
167
+ this.pending.clear()
168
+ }
169
+
170
+ async spawnAppServer() {
171
+ await ensureCodexAppServerSupport()
172
+ const codexExecutable = resolveCodexExecutable()
173
+
155
174
  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 } : {}),
175
+ const child = spawn(
176
+ codexExecutable.command,
177
+ [...codexExecutable.argsPrefix, 'app-server', '--listen', 'stdio://'],
178
+ {
179
+ env: {
180
+ ...process.env,
181
+ ...(this.codexHome ? { CODEX_HOME: this.codexHome } : {}),
182
+ },
183
+ stdio: ['pipe', 'pipe', 'pipe'],
160
184
  },
161
- stdio: ['pipe', 'pipe', 'pipe'],
185
+ )
186
+
187
+ child.stdout.setEncoding('utf8')
188
+ child.stdout.on('data', (chunk) => {
189
+ this.consumeStdout(chunk)
190
+ })
191
+
192
+ child.stderr.on('data', (chunk) => {
193
+ const message = chunk.toString().trim()
194
+ if (!message) {
195
+ return
196
+ }
197
+
198
+ const lines = message.split(/\r?\n/)
199
+ this.lastStderrMessage = lines[lines.length - 1] ?? message
200
+ console.error(`[codex] ${message}`)
162
201
  })
163
202
 
164
203
  child.once('error', (error) => {
165
204
  reject(
166
205
  new Error(
167
206
  error.code === 'ENOENT'
168
- ? 'The `codex` command is not installed on this machine.'
207
+ ? 'Codex CLI is not available. Reinstall `codex-usage-dashboard` or install it globally with `npm install -g @openai/codex`.'
169
208
  : error.message,
170
209
  ),
171
210
  )
172
211
  })
173
212
 
213
+ child.once('exit', (code, signal) => {
214
+ if (this.child === child) {
215
+ this.child = null
216
+ }
217
+
218
+ const expectedShutdown = this.isClosing
219
+ this.isClosing = false
220
+
221
+ if (expectedShutdown) {
222
+ return
223
+ }
224
+
225
+ const error = buildCodexAppServerExitError(
226
+ code,
227
+ signal,
228
+ this.lastStderrMessage,
229
+ )
230
+ this.rejectPending(error)
231
+ })
232
+
174
233
  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
234
  resolve(child)
181
235
  })
182
236
  })
@@ -236,20 +290,20 @@ async function runPairCommand(args) {
236
290
  }),
237
291
  })
238
292
 
239
- const payload = await parseJsonResponse(response)
293
+ const payload = await parseResponseBody(response)
240
294
  if (!response.ok) {
241
- throw new Error(payload.error ?? 'Pairing failed.')
295
+ throw new Error(buildHttpErrorMessage(response, payload, 'Pairing failed.'))
242
296
  }
243
297
 
244
298
  const config = {
245
299
  authMode: 'website-paired',
246
300
  codexHome,
247
- deviceId: payload.deviceId,
248
- deviceToken: payload.deviceToken,
301
+ deviceId: payload.data.deviceId,
302
+ deviceToken: payload.data.deviceToken,
249
303
  dashboardOrigin: new URL(pairUrl).origin,
250
304
  label: device.label,
251
- pollMs: payload.pollMs ?? DEFAULT_POLL_MS,
252
- syncUrl: payload.syncUrl,
305
+ pollMs: payload.data.pollMs ?? DEFAULT_POLL_MS,
306
+ syncUrl: payload.data.syncUrl,
253
307
  }
254
308
 
255
309
  await writeConfig(codexHome, config)
@@ -313,24 +367,26 @@ async function runConnectCommand(args) {
313
367
  }),
314
368
  })
315
369
 
316
- const payload = await parseJsonResponse(response)
370
+ const payload = await parseResponseBody(response)
317
371
  if (!response.ok) {
318
- throw new Error(payload.error ?? 'Unable to connect this machine.')
372
+ throw new Error(
373
+ buildHttpErrorMessage(response, payload, 'Unable to connect this machine.'),
374
+ )
319
375
  }
320
376
 
321
377
  const config = {
322
378
  authMode: 'guest-link',
323
379
  codexHome,
324
380
  dashboardOrigin: siteOrigin,
325
- deviceId: payload.deviceId,
326
- deviceToken: payload.deviceToken,
381
+ deviceId: payload.data.deviceId,
382
+ deviceToken: payload.data.deviceToken,
327
383
  label: device.label,
328
- pollMs: payload.pollMs ?? DEFAULT_POLL_MS,
329
- syncUrl: payload.syncUrl,
384
+ pollMs: payload.data.pollMs ?? DEFAULT_POLL_MS,
385
+ syncUrl: payload.data.syncUrl,
330
386
  }
331
387
 
332
388
  await writeConfig(codexHome, config)
333
- await openDashboard(payload.dashboardUrl)
389
+ await openDashboard(payload.data.dashboardUrl)
334
390
  console.log('Dashboard opened.')
335
391
  console.log(`Config saved to ${resolveConfigPath(codexHome)}`)
336
392
  console.log(
@@ -447,9 +503,9 @@ async function syncOnce(client, config, args) {
447
503
  }),
448
504
  })
449
505
 
450
- const payload = await parseJsonResponse(response)
506
+ const payload = await parseResponseBody(response)
451
507
  if (!response.ok) {
452
- throw new Error(payload.error ?? 'Sync failed.')
508
+ throw new Error(buildHttpErrorMessage(response, payload, 'Sync failed.'))
453
509
  }
454
510
  }
455
511
 
@@ -465,12 +521,14 @@ async function resolveExistingDashboardUrl(config, args) {
465
521
  }),
466
522
  })
467
523
 
468
- const payload = await parseJsonResponse(response)
524
+ const payload = await parseResponseBody(response)
469
525
  if (!response.ok) {
470
- throw new Error(payload.error ?? 'Unable to open the dashboard.')
526
+ throw new Error(
527
+ buildHttpErrorMessage(response, payload, 'Unable to open the dashboard.'),
528
+ )
471
529
  }
472
530
 
473
- return payload.dashboardUrl ?? null
531
+ return payload.data.dashboardUrl ?? null
474
532
  }
475
533
 
476
534
  const siteOrigin =
@@ -489,7 +547,7 @@ async function readSnapshot(client, failWhenLoggedOut) {
489
547
  if (!accountState.account) {
490
548
  if (failWhenLoggedOut) {
491
549
  throw new Error(
492
- 'No logged-in Codex account was found. Run `codex login` and try again.',
550
+ 'No logged-in Codex account was found. Run `npx @openai/codex@latest login` (or `codex login` if installed globally) and try again.',
493
551
  )
494
552
  }
495
553
 
@@ -598,6 +656,161 @@ function openInBrowser(url) {
598
656
  })
599
657
  }
600
658
 
659
+ async function ensureCodexAppServerSupport() {
660
+ if (!codexAppServerSupportPromise) {
661
+ codexAppServerSupportPromise = inspectCodexCliForAppServer().catch(
662
+ (error) => {
663
+ codexAppServerSupportPromise = null
664
+ throw error
665
+ },
666
+ )
667
+ }
668
+
669
+ await codexAppServerSupportPromise
670
+ }
671
+
672
+ async function inspectCodexCliForAppServer() {
673
+ const codexExecutable = resolveCodexExecutable()
674
+ const help = await runCommandCapture(
675
+ codexExecutable.command,
676
+ [...codexExecutable.argsPrefix, '--help'],
677
+ { timeoutMs: CODEX_HELP_TIMEOUT_MS },
678
+ )
679
+
680
+ const helpText = `${help.stdout}\n${help.stderr}`
681
+ if (help.code === 0 && /\bapp-server\b/.test(helpText)) {
682
+ return
683
+ }
684
+
685
+ const version = await runCommandCapture(
686
+ codexExecutable.command,
687
+ [...codexExecutable.argsPrefix, '--version'],
688
+ { timeoutMs: CODEX_HELP_TIMEOUT_MS },
689
+ )
690
+ const versionText = normalizeCodexVersion(version.stdout || version.stderr)
691
+ throw new Error(
692
+ `Resolved Codex CLI ${versionText} (${codexExecutable.label}) does not support \`codex app-server\`. Reinstall \`codex-usage-dashboard\` or update Codex with \`npm install -g @openai/codex\`, then rerun this command.`,
693
+ )
694
+ }
695
+
696
+ function resolveCodexExecutable() {
697
+ if (resolvedCodexExecutable) {
698
+ return resolvedCodexExecutable
699
+ }
700
+
701
+ const bundledBinPath = resolveBundledCodexBin()
702
+ if (bundledBinPath) {
703
+ resolvedCodexExecutable = {
704
+ argsPrefix: [bundledBinPath],
705
+ command: process.execPath,
706
+ label: 'bundled @openai/codex',
707
+ }
708
+ return resolvedCodexExecutable
709
+ }
710
+
711
+ resolvedCodexExecutable = {
712
+ argsPrefix: [],
713
+ command: 'codex',
714
+ label: 'global codex',
715
+ }
716
+ return resolvedCodexExecutable
717
+ }
718
+
719
+ function resolveBundledCodexBin() {
720
+ try {
721
+ return require.resolve('@openai/codex/bin/codex.js')
722
+ } catch {
723
+ return null
724
+ }
725
+ }
726
+
727
+ function runCommandCapture(command, args, { timeoutMs }) {
728
+ return new Promise((resolve, reject) => {
729
+ let stdout = ''
730
+ let stderr = ''
731
+ let settled = false
732
+
733
+ const child = spawn(command, args, {
734
+ env: process.env,
735
+ stdio: ['ignore', 'pipe', 'pipe'],
736
+ })
737
+
738
+ const timeout = setTimeout(() => {
739
+ if (settled) {
740
+ return
741
+ }
742
+
743
+ settled = true
744
+ child.kill('SIGTERM')
745
+ reject(
746
+ new Error(
747
+ `Timed out while probing \`${command} ${args.join(' ')}\`.`,
748
+ ),
749
+ )
750
+ }, timeoutMs)
751
+
752
+ child.stdout.on('data', (chunk) => {
753
+ stdout += chunk.toString()
754
+ })
755
+
756
+ child.stderr.on('data', (chunk) => {
757
+ stderr += chunk.toString()
758
+ })
759
+
760
+ child.once('error', (error) => {
761
+ if (settled) {
762
+ return
763
+ }
764
+
765
+ settled = true
766
+ clearTimeout(timeout)
767
+ reject(
768
+ new Error(
769
+ error.code === 'ENOENT'
770
+ ? 'The `codex` command is not installed on this machine.'
771
+ : error.message,
772
+ ),
773
+ )
774
+ })
775
+
776
+ child.once('close', (code, signal) => {
777
+ if (settled) {
778
+ return
779
+ }
780
+
781
+ settled = true
782
+ clearTimeout(timeout)
783
+ resolve({
784
+ code,
785
+ signal,
786
+ stderr: stderr.trim(),
787
+ stdout: stdout.trim(),
788
+ })
789
+ })
790
+ })
791
+ }
792
+
793
+ function normalizeCodexVersion(versionText) {
794
+ const normalized = versionText.trim().replace(/^codex-cli\s+/i, '')
795
+ return normalized || 'unknown'
796
+ }
797
+
798
+ function buildCodexAppServerExitError(code, signal, stderrMessage) {
799
+ const exitDetail =
800
+ typeof code === 'number'
801
+ ? `exit code ${code}`
802
+ : signal
803
+ ? `signal ${signal}`
804
+ : 'no exit code'
805
+
806
+ const message = [`Codex app-server exited unexpectedly (${exitDetail}).`]
807
+ if (stderrMessage) {
808
+ message.push(stderrMessage)
809
+ }
810
+
811
+ return new Error(message.join(' '))
812
+ }
813
+
601
814
  async function writeConfig(codexHome, config) {
602
815
  await mkdir(codexHome, { recursive: true })
603
816
  await writeFile(
@@ -616,13 +829,81 @@ async function readConfig(codexHome) {
616
829
  return JSON.parse(await readFile(configPath, 'utf8'))
617
830
  }
618
831
 
619
- async function parseJsonResponse(response) {
620
- const payload = await response.json().catch(() => null)
621
- if (!payload || typeof payload !== 'object') {
622
- return {}
832
+ async function parseResponseBody(response) {
833
+ const text = (await response.text().catch(() => '')).trim()
834
+ if (!text) {
835
+ return { data: {}, text: '' }
836
+ }
837
+
838
+ try {
839
+ const parsed = JSON.parse(text)
840
+ if (parsed && typeof parsed === 'object') {
841
+ return { data: parsed, text }
842
+ }
843
+ } catch {
844
+ // Fall back to the raw text when the response body is not JSON.
845
+ }
846
+
847
+ return { data: {}, text }
848
+ }
849
+
850
+ function buildHttpErrorMessage(response, payload, fallbackMessage) {
851
+ const bodyError =
852
+ typeof payload.data?.error === 'string' && payload.data.error.trim()
853
+ ? payload.data.error.trim()
854
+ : null
855
+ const plainText =
856
+ payload.text && !looksLikeHtml(payload.text)
857
+ ? payload.text.replace(/\s+/g, ' ').trim()
858
+ : ''
859
+ const statusLabel = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
860
+ const vercelError = response.headers.get('x-vercel-error')
861
+ const vercelId = response.headers.get('x-vercel-id')
862
+
863
+ const detail = bodyError ?? truncateText(plainText, 240)
864
+ let message = detail
865
+ ? `${fallbackMessage} ${detail}`
866
+ : `${fallbackMessage} HTTP ${statusLabel}.`
867
+
868
+ if (!detail) {
869
+ return appendHttpContext(message, vercelError, vercelId)
870
+ }
871
+
872
+ if (!bodyError) {
873
+ message = `${message} (HTTP ${statusLabel})`
874
+ }
875
+
876
+ return appendHttpContext(message, vercelError, vercelId)
877
+ }
878
+
879
+ function appendHttpContext(message, vercelError, vercelId) {
880
+ const context = []
881
+
882
+ if (vercelError) {
883
+ context.push(`Vercel error: ${vercelError}`)
884
+ }
885
+
886
+ if (vercelId) {
887
+ context.push(`request id: ${vercelId}`)
888
+ }
889
+
890
+ if (!context.length) {
891
+ return message
892
+ }
893
+
894
+ return `${message} [${context.join('; ')}]`
895
+ }
896
+
897
+ function looksLikeHtml(text) {
898
+ return /^<!doctype html>|^<html[\s>]/i.test(text)
899
+ }
900
+
901
+ function truncateText(text, maxLength) {
902
+ if (text.length <= maxLength) {
903
+ return text
623
904
  }
624
905
 
625
- return payload
906
+ return `${text.slice(0, maxLength - 1)}…`
626
907
  }
627
908
 
628
909
  function waitForTermination(cleanup) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "codex-usage-dashboard",
3
3
  "private": false,
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
5
  "files": [
6
6
  "README.md",
7
7
  "bin"
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@fontsource-variable/geist": "^5.2.8",
30
+ "@openai/codex": "^0.118.0",
30
31
  "@radix-ui/react-slot": "^1.2.4",
31
32
  "@supabase/supabase-js": "^2.101.1",
32
33
  "@tanstack/react-query": "^5.96.2",