codex-usage-dashboard 0.1.1 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/bin/codex-usage.js +256 -65
  3. package/package.json +23 -21
package/README.md CHANGED
@@ -50,7 +50,13 @@ The Vite dev server now serves the local pairing and sync API routes under `/api
50
50
  Run this on any machine:
51
51
 
52
52
  ```bash
53
- npx codex-usage-dashboard@latest connect --site "https://your-site.vercel.app"
53
+ npx codex-usage-dashboard@latest connect --site "https://codex-use-age.vercel.app"
54
+ ```
55
+
56
+ For local development in this repo, use:
57
+
58
+ ```bash
59
+ npm run connect -- --site "http://localhost:5173"
54
60
  ```
55
61
 
56
62
  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.
@@ -81,7 +87,7 @@ If the same machine is already connected, rerun the same command to reopen the d
81
87
  The generated command looks like:
82
88
 
83
89
  ```bash
84
- npx codex-usage-dashboard@latest pair "https://your-site.vercel.app/api/pair/complete?token=..."
90
+ npx codex-usage-dashboard@latest pair "https://codex-use-age.vercel.app/api/pair/complete?token=..."
85
91
  ```
86
92
 
87
93
  ## Live sync after pairing
@@ -101,6 +107,14 @@ The saved device token is read from the same Codex home that the CLI uses.
101
107
  - `npm run sync -- --watch`: local version of the hosted sync command
102
108
  - `npm run collector`: legacy single-operator collector script
103
109
 
110
+ ## Publishing
111
+
112
+ 1. Run `npm version patch` (or `minor` / `major`) before every new publish.
113
+ 2. Run `npm run release:check` to verify the tarball contents and confirm the version is still unpublished.
114
+ 3. Run `npm publish`.
115
+
116
+ `npm publish` cannot overwrite an existing version on npm, so repeating a publish for the same version will fail with `E403`.
117
+
104
118
  ## Tario-specific Git workflow for Codex
105
119
 
106
120
  If the current machine or user context indicates Tario is the operator, treat that as a master-by-default environment.
@@ -290,20 +290,20 @@ async function runPairCommand(args) {
290
290
  }),
291
291
  })
292
292
 
293
- const payload = await parseJsonResponse(response)
293
+ const payload = await parseResponseBody(response)
294
294
  if (!response.ok) {
295
- throw new Error(payload.error ?? 'Pairing failed.')
295
+ throw new Error(buildHttpErrorMessage(response, payload, 'Pairing failed.'))
296
296
  }
297
297
 
298
298
  const config = {
299
299
  authMode: 'website-paired',
300
300
  codexHome,
301
- deviceId: payload.deviceId,
302
- deviceToken: payload.deviceToken,
301
+ deviceId: payload.data.deviceId,
302
+ deviceToken: payload.data.deviceToken,
303
303
  dashboardOrigin: new URL(pairUrl).origin,
304
304
  label: device.label,
305
- pollMs: payload.pollMs ?? DEFAULT_POLL_MS,
306
- syncUrl: payload.syncUrl,
305
+ pollMs: payload.data.pollMs ?? DEFAULT_POLL_MS,
306
+ syncUrl: payload.data.syncUrl,
307
307
  }
308
308
 
309
309
  await writeConfig(codexHome, config)
@@ -325,71 +325,71 @@ async function runConnectCommand(args) {
325
325
  const codexHome = resolveCodexHome(args.options['codex-home'])
326
326
  const existingConfig = await readConfig(codexHome)
327
327
  const client = new StdioCodexClient({ codexHome })
328
+ const siteOriginFromArgs = resolveSiteOrigin(args.options.site)
328
329
 
329
330
  try {
330
331
  await client.connect()
331
332
 
332
333
  if (existingConfig) {
333
- const dashboardUrl = await resolveExistingDashboardUrl(existingConfig, args)
334
+ try {
335
+ const dashboardUrl = await resolveExistingDashboardUrl(existingConfig, args)
336
+ let dashboardOpenState = null
334
337
 
335
- if (dashboardUrl) {
336
- await openDashboard(dashboardUrl)
337
- }
338
+ if (dashboardUrl) {
339
+ dashboardOpenState = await openDashboard(dashboardUrl)
340
+ }
338
341
 
339
- await syncOnce(client, existingConfig, args)
340
- console.log('Dashboard opened.')
342
+ await syncOnce(client, existingConfig, args)
343
+ logDashboardOpenState(dashboardOpenState)
341
344
 
342
- if (args.options.watch) {
343
- await runWatchLoop(client, existingConfig, args)
344
- }
345
+ if (args.options.watch) {
346
+ await runWatchLoop(client, existingConfig, args)
347
+ }
345
348
 
346
- return
349
+ return
350
+ } catch (error) {
351
+ if (!isRevokedDeviceError(error)) {
352
+ throw error
353
+ }
354
+
355
+ const siteOrigin =
356
+ siteOriginFromArgs ??
357
+ existingConfig.dashboardOrigin ??
358
+ deriveOriginFromUrl(existingConfig.syncUrl)
359
+
360
+ if (!siteOrigin) {
361
+ throw new Error(
362
+ `This device was unlinked from the dashboard. Rerun \`${NPX_COMMAND} connect --site "https://codex-use-age.vercel.app"\` to create a new connection.`,
363
+ )
364
+ }
365
+
366
+ console.log(
367
+ 'This device was unlinked from the dashboard. Creating a new connection...',
368
+ )
369
+
370
+ const refreshedConfig = await startConnectFlow(
371
+ client,
372
+ args,
373
+ codexHome,
374
+ siteOrigin,
375
+ )
376
+
377
+ if (args.options.watch) {
378
+ await runWatchLoop(client, refreshedConfig, args)
379
+ }
380
+
381
+ return
382
+ }
347
383
  }
348
384
 
349
- const siteOrigin = resolveSiteOrigin(args.options.site)
385
+ const siteOrigin = siteOriginFromArgs
350
386
  if (!siteOrigin) {
351
387
  throw new Error(
352
388
  'Pass --site <url> the first time you run connect, or set CODEX_USAGE_SITE_URL.',
353
389
  )
354
390
  }
355
391
 
356
- const snapshot = await readSnapshot(client, true)
357
- const device = buildDevicePayload(args, codexHome)
358
- const response = await fetch(new URL('/api/connect/start', siteOrigin), {
359
- method: 'POST',
360
- headers: {
361
- 'Content-Type': 'application/json',
362
- },
363
- body: JSON.stringify({
364
- accountState: snapshot.accountState,
365
- device,
366
- rateLimits: snapshot.rateLimits,
367
- }),
368
- })
369
-
370
- const payload = await parseJsonResponse(response)
371
- if (!response.ok) {
372
- throw new Error(payload.error ?? 'Unable to connect this machine.')
373
- }
374
-
375
- const config = {
376
- authMode: 'guest-link',
377
- codexHome,
378
- dashboardOrigin: siteOrigin,
379
- deviceId: payload.deviceId,
380
- deviceToken: payload.deviceToken,
381
- label: device.label,
382
- pollMs: payload.pollMs ?? DEFAULT_POLL_MS,
383
- syncUrl: payload.syncUrl,
384
- }
385
-
386
- await writeConfig(codexHome, config)
387
- await openDashboard(payload.dashboardUrl)
388
- console.log('Dashboard opened.')
389
- console.log(`Config saved to ${resolveConfigPath(codexHome)}`)
390
- console.log(
391
- `Next: rerun \`${NPX_COMMAND} connect --site "${siteOrigin}"\` to reopen this dashboard, or \`${NPX_COMMAND} sync --watch\` for live updates only.`,
392
- )
392
+ const config = await startConnectFlow(client, args, codexHome, siteOrigin)
393
393
 
394
394
  if (args.options.watch) {
395
395
  await runWatchLoop(client, config, args)
@@ -399,6 +399,50 @@ async function runConnectCommand(args) {
399
399
  }
400
400
  }
401
401
 
402
+ async function startConnectFlow(client, args, codexHome, siteOrigin) {
403
+ const snapshot = await readSnapshot(client, true)
404
+ const device = buildDevicePayload(args, codexHome)
405
+ const response = await fetch(new URL('/api/connect/start', siteOrigin), {
406
+ method: 'POST',
407
+ headers: {
408
+ 'Content-Type': 'application/json',
409
+ },
410
+ body: JSON.stringify({
411
+ accountState: snapshot.accountState,
412
+ device,
413
+ rateLimits: snapshot.rateLimits,
414
+ }),
415
+ })
416
+
417
+ const payload = await parseResponseBody(response)
418
+ if (!response.ok) {
419
+ throw new Error(
420
+ buildHttpErrorMessage(response, payload, 'Unable to connect this machine.'),
421
+ )
422
+ }
423
+
424
+ const config = {
425
+ authMode: 'guest-link',
426
+ codexHome,
427
+ dashboardOrigin: siteOrigin,
428
+ deviceId: payload.data.deviceId,
429
+ deviceToken: payload.data.deviceToken,
430
+ label: device.label,
431
+ pollMs: payload.data.pollMs ?? DEFAULT_POLL_MS,
432
+ syncUrl: payload.data.syncUrl,
433
+ }
434
+
435
+ await writeConfig(codexHome, config)
436
+ const dashboardOpenState = await openDashboard(payload.data.dashboardUrl)
437
+ logDashboardOpenState(dashboardOpenState)
438
+ console.log(`Config saved to ${resolveConfigPath(codexHome)}`)
439
+ console.log(
440
+ `Next: rerun \`${NPX_COMMAND} connect --site "${siteOrigin}"\` to reopen this dashboard, or \`${NPX_COMMAND} sync --watch\` for live updates only.`,
441
+ )
442
+
443
+ return config
444
+ }
445
+
402
446
  async function runSyncCommand(args) {
403
447
  const codexHome = resolveCodexHome(args.options['codex-home'])
404
448
  const config = await readConfig(codexHome)
@@ -501,9 +545,9 @@ async function syncOnce(client, config, args) {
501
545
  }),
502
546
  })
503
547
 
504
- const payload = await parseJsonResponse(response)
548
+ const payload = await parseResponseBody(response)
505
549
  if (!response.ok) {
506
- throw new Error(payload.error ?? 'Sync failed.')
550
+ throw new Error(buildHttpErrorMessage(response, payload, 'Sync failed.'))
507
551
  }
508
552
  }
509
553
 
@@ -519,12 +563,14 @@ async function resolveExistingDashboardUrl(config, args) {
519
563
  }),
520
564
  })
521
565
 
522
- const payload = await parseJsonResponse(response)
566
+ const payload = await parseResponseBody(response)
523
567
  if (!response.ok) {
524
- throw new Error(payload.error ?? 'Unable to open the dashboard.')
568
+ throw new Error(
569
+ buildHttpErrorMessage(response, payload, 'Unable to open the dashboard.'),
570
+ )
525
571
  }
526
572
 
527
- return payload.dashboardUrl ?? null
573
+ return payload.data.dashboardUrl ?? null
528
574
  }
529
575
 
530
576
  const siteOrigin =
@@ -571,8 +617,21 @@ function buildDevicePayload(args, codexHome, fallbackLabel) {
571
617
  async function openDashboard(url) {
572
618
  try {
573
619
  await openInBrowser(url)
620
+ return 'browser'
574
621
  } catch {
575
622
  console.log(`Open this URL in your browser: ${url}`)
623
+ return 'manual'
624
+ }
625
+ }
626
+
627
+ function logDashboardOpenState(state) {
628
+ if (state === 'browser') {
629
+ console.log('Dashboard URL sent to your browser.')
630
+ return
631
+ }
632
+
633
+ if (state === 'manual') {
634
+ console.log('Dashboard URL ready.')
576
635
  }
577
636
  }
578
637
 
@@ -616,6 +675,18 @@ function resolveSiteOrigin(configuredValue) {
616
675
  return new URL(value).origin
617
676
  }
618
677
 
678
+ function deriveOriginFromUrl(value) {
679
+ if (typeof value !== 'string' || !value) {
680
+ return null
681
+ }
682
+
683
+ try {
684
+ return new URL(value).origin
685
+ } catch {
686
+ return null
687
+ }
688
+ }
689
+
619
690
  function resolveCodexHome(configuredValue) {
620
691
  const home = configuredValue ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex')
621
692
 
@@ -822,16 +893,136 @@ async function readConfig(codexHome) {
822
893
  return null
823
894
  }
824
895
 
825
- return JSON.parse(await readFile(configPath, 'utf8'))
896
+ const rawConfig = JSON.parse(await readFile(configPath, 'utf8'))
897
+ const normalizedConfig = normalizeConfig(rawConfig)
898
+
899
+ if (JSON.stringify(normalizedConfig) !== JSON.stringify(rawConfig)) {
900
+ await writeConfig(codexHome, normalizedConfig)
901
+ }
902
+
903
+ return normalizedConfig
904
+ }
905
+
906
+ function normalizeConfig(config) {
907
+ if (!config || typeof config !== 'object') {
908
+ return config
909
+ }
910
+
911
+ const normalizedConfig = { ...config }
912
+
913
+ if (
914
+ !normalizedConfig.dashboardOrigin &&
915
+ typeof normalizedConfig.syncUrl === 'string'
916
+ ) {
917
+ try {
918
+ normalizedConfig.dashboardOrigin = new URL(
919
+ normalizedConfig.syncUrl,
920
+ ).origin
921
+ } catch {
922
+ // Leave the saved origin untouched when the sync URL is malformed.
923
+ }
924
+ }
925
+
926
+ if (looksLikeLegacyGuestLinkConfig(config)) {
927
+ normalizedConfig.authMode = 'guest-link'
928
+ }
929
+
930
+ return normalizedConfig
931
+ }
932
+
933
+ function looksLikeLegacyGuestLinkConfig(config) {
934
+ return (
935
+ !config.authMode &&
936
+ !config.dashboardOrigin &&
937
+ typeof config.deviceToken === 'string' &&
938
+ config.deviceToken.length > 0 &&
939
+ typeof config.syncUrl === 'string' &&
940
+ config.syncUrl.length > 0
941
+ )
942
+ }
943
+
944
+ async function parseResponseBody(response) {
945
+ const text = (await response.text().catch(() => '')).trim()
946
+ if (!text) {
947
+ return { data: {}, text: '' }
948
+ }
949
+
950
+ try {
951
+ const parsed = JSON.parse(text)
952
+ if (parsed && typeof parsed === 'object') {
953
+ return { data: parsed, text }
954
+ }
955
+ } catch {
956
+ // Fall back to the raw text when the response body is not JSON.
957
+ }
958
+
959
+ return { data: {}, text }
960
+ }
961
+
962
+ function buildHttpErrorMessage(response, payload, fallbackMessage) {
963
+ const bodyError =
964
+ typeof payload.data?.error === 'string' && payload.data.error.trim()
965
+ ? payload.data.error.trim()
966
+ : null
967
+ const plainText =
968
+ payload.text && !looksLikeHtml(payload.text)
969
+ ? payload.text.replace(/\s+/g, ' ').trim()
970
+ : ''
971
+ const statusLabel = `${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
972
+ const vercelError = response.headers.get('x-vercel-error')
973
+ const vercelId = response.headers.get('x-vercel-id')
974
+
975
+ const detail = bodyError ?? truncateText(plainText, 240)
976
+ let message = detail
977
+ ? `${fallbackMessage} ${detail}`
978
+ : `${fallbackMessage} HTTP ${statusLabel}.`
979
+
980
+ if (!detail) {
981
+ return appendHttpContext(message, vercelError, vercelId)
982
+ }
983
+
984
+ if (!bodyError) {
985
+ message = `${message} (HTTP ${statusLabel})`
986
+ }
987
+
988
+ return appendHttpContext(message, vercelError, vercelId)
989
+ }
990
+
991
+ function isRevokedDeviceError(error) {
992
+ return (
993
+ error instanceof Error &&
994
+ error.message.includes('This device is no longer authorized.')
995
+ )
996
+ }
997
+
998
+ function appendHttpContext(message, vercelError, vercelId) {
999
+ const context = []
1000
+
1001
+ if (vercelError) {
1002
+ context.push(`Vercel error: ${vercelError}`)
1003
+ }
1004
+
1005
+ if (vercelId) {
1006
+ context.push(`request id: ${vercelId}`)
1007
+ }
1008
+
1009
+ if (!context.length) {
1010
+ return message
1011
+ }
1012
+
1013
+ return `${message} [${context.join('; ')}]`
1014
+ }
1015
+
1016
+ function looksLikeHtml(text) {
1017
+ return /^<!doctype html>|^<html[\s>]/i.test(text)
826
1018
  }
827
1019
 
828
- async function parseJsonResponse(response) {
829
- const payload = await response.json().catch(() => null)
830
- if (!payload || typeof payload !== 'object') {
831
- return {}
1020
+ function truncateText(text, maxLength) {
1021
+ if (text.length <= maxLength) {
1022
+ return text
832
1023
  }
833
1024
 
834
- return payload
1025
+ return `${text.slice(0, maxLength - 1)}…`
835
1026
  }
836
1027
 
837
1028
  function waitForTermination(cleanup) {
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "codex-usage-dashboard",
3
3
  "private": false,
4
- "version": "0.1.1",
4
+ "version": "0.1.3",
5
5
  "files": [
6
6
  "README.md",
7
7
  "bin"
8
8
  ],
9
9
  "type": "module",
10
10
  "bin": {
11
- "codex-usage": "./bin/codex-usage.js",
12
- "codex-usage-dashboard": "./bin/codex-usage.js"
11
+ "codex-usage": "bin/codex-usage.js",
12
+ "codex-usage-dashboard": "bin/codex-usage.js"
13
13
  },
14
14
  "scripts": {
15
15
  "dev": "vite",
@@ -23,45 +23,47 @@
23
23
  "setup:hosted": "tsx scripts/setup-hosted.ts",
24
24
  "setup:local": "tsx scripts/setup-local.ts",
25
25
  "collector": "tsx scripts/codex-collector.ts",
26
- "collector:once": "tsx scripts/codex-collector.ts --once"
26
+ "collector:once": "tsx scripts/codex-collector.ts --once",
27
+ "release:check": "node ./scripts/check-publish.mjs",
28
+ "prepublishOnly": "npm run release:check"
27
29
  },
28
30
  "dependencies": {
31
+ "@openai/codex": "^0.118.0"
32
+ },
33
+ "devDependencies": {
34
+ "@eslint/js": "^9.39.4",
29
35
  "@fontsource-variable/geist": "^5.2.8",
30
- "@openai/codex": "^0.118.0",
31
36
  "@radix-ui/react-slot": "^1.2.4",
37
+ "@tailwindcss/vite": "^4.2.2",
32
38
  "@supabase/supabase-js": "^2.101.1",
33
39
  "@tanstack/react-query": "^5.96.2",
34
40
  "@tanstack/react-query-devtools": "^5.96.2",
35
41
  "@tanstack/react-router": "^1.168.10",
42
+ "@tanstack/router-plugin": "^1.167.12",
36
43
  "@tanstack/router-devtools": "^1.166.11",
44
+ "@types/node": "^24.12.0",
45
+ "@types/react": "^19.2.14",
46
+ "@types/react-dom": "^19.2.3",
47
+ "@vitejs/plugin-react": "^6.0.1",
37
48
  "class-variance-authority": "^0.7.1",
38
49
  "clsx": "^2.1.1",
39
50
  "dotenv": "^17.4.1",
51
+ "eslint": "^9.39.4",
52
+ "eslint-plugin-react-hooks": "^7.0.1",
53
+ "eslint-plugin-react-refresh": "^0.5.2",
54
+ "globals": "^17.4.0",
40
55
  "lucide-react": "^1.7.0",
41
56
  "radix-ui": "^1.4.3",
42
57
  "react": "^19.2.4",
43
58
  "react-dom": "^19.2.4",
44
59
  "shadcn": "^4.1.2",
60
+ "tailwindcss": "^4.2.2",
45
61
  "tailwind-merge": "^3.5.0",
46
62
  "tw-animate-css": "^1.4.0",
47
- "zod": "^4.3.6"
48
- },
49
- "devDependencies": {
50
- "@eslint/js": "^9.39.4",
51
- "@tailwindcss/vite": "^4.2.2",
52
- "@tanstack/router-plugin": "^1.167.12",
53
- "@types/node": "^24.12.0",
54
- "@types/react": "^19.2.14",
55
- "@types/react-dom": "^19.2.3",
56
- "@vitejs/plugin-react": "^6.0.1",
57
- "eslint": "^9.39.4",
58
- "eslint-plugin-react-hooks": "^7.0.1",
59
- "eslint-plugin-react-refresh": "^0.5.2",
60
- "globals": "^17.4.0",
61
- "tailwindcss": "^4.2.2",
62
63
  "tsx": "^4.21.0",
63
64
  "typescript": "~5.9.3",
64
65
  "typescript-eslint": "^8.57.0",
65
- "vite": "^8.0.1"
66
+ "vite": "^8.0.1",
67
+ "zod": "^4.3.6"
66
68
  }
67
69
  }