codex-usage-dashboard 0.1.2 → 0.1.4
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 +5 -118
- package/bin/codex-usage.js +170 -51
- package/package.json +23 -21
package/README.md
CHANGED
|
@@ -1,121 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# codex usage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
i cycle between multiple codex accounts and wanted to keep track of them so i created this
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
website: https://codexusage.vercel.app
|
|
6
|
+
installation: `npx codex-usage-dashboard@latest connect --site "https://codexusage.vercel.app"`
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
- Supabase Auth + Postgres
|
|
9
|
-
- Vercel Functions for pairing and sync ingest
|
|
10
|
-
- Local Codex access through a bundled `@openai/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:
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
npx codex-usage-dashboard@latest connect --site "https://your-site.vercel.app"
|
|
54
|
-
```
|
|
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
|
-
|
|
64
|
-
That command:
|
|
65
|
-
|
|
66
|
-
- starts a local `codex app-server`
|
|
67
|
-
- reads the current Codex account and rate limits
|
|
68
|
-
- provisions a dashboard owner session without requiring Google first
|
|
69
|
-
- opens the hosted dashboard in the browser
|
|
70
|
-
- stores the device token under `CODEX_HOME` in `codex-usage-sync.json`
|
|
71
|
-
- pushes the first snapshot to the dashboard
|
|
72
|
-
|
|
73
|
-
If the same machine is already connected, rerun the same command to reopen the dashboard and refresh the local snapshot.
|
|
74
|
-
|
|
75
|
-
## Website pairing flow
|
|
76
|
-
|
|
77
|
-
1. Sign in on the website.
|
|
78
|
-
2. Click `Create pairing command`.
|
|
79
|
-
3. Run the generated command on the machine you want to pair.
|
|
80
|
-
|
|
81
|
-
The generated command looks like:
|
|
82
|
-
|
|
83
|
-
```bash
|
|
84
|
-
npx codex-usage-dashboard@latest pair "https://your-site.vercel.app/api/pair/complete?token=..."
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
## Live sync after pairing
|
|
88
|
-
|
|
89
|
-
To keep syncing from that machine:
|
|
90
|
-
|
|
91
|
-
```bash
|
|
92
|
-
npx codex-usage-dashboard@latest sync --watch
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
The saved device token is read from the same Codex home that the CLI uses.
|
|
96
|
-
|
|
97
|
-
## Dev scripts
|
|
98
|
-
|
|
99
|
-
- `npm run connect -- --site "<site-url>"`: local version of the direct connect command
|
|
100
|
-
- `npm run pair -- "<pair-url>"`: local version of the hosted pairing command
|
|
101
|
-
- `npm run sync -- --watch`: local version of the hosted sync command
|
|
102
|
-
- `npm run collector`: legacy single-operator collector script
|
|
103
|
-
|
|
104
|
-
## Tario-specific Git workflow for Codex
|
|
105
|
-
|
|
106
|
-
If the current machine or user context indicates Tario is the operator, treat that as a master-by-default environment.
|
|
107
|
-
|
|
108
|
-
- Use the local machine identity to decide this.
|
|
109
|
-
- Treat `scutil --get ComputerName` returning `tario` as the primary signal.
|
|
110
|
-
- Treat `/Users/tarioyou` and the local user `tarioyou` as reinforcing signals.
|
|
111
|
-
- In that Tario context, unless otherwise specified, work directly on `master`.
|
|
112
|
-
- If the user explicitly says to work on a branch, create or switch to that branch and work there.
|
|
113
|
-
- Do not create, switch to, or push feature branches by default.
|
|
114
|
-
- Do not default to PR-first workflows or branch-based workflows unless the user asks for them.
|
|
115
|
-
- This rule is specific to Tario's environment; when the operator is not Tario, normal branch workflows remain allowed.
|
|
116
|
-
|
|
117
|
-
## Notes
|
|
118
|
-
|
|
119
|
-
- The Vercel API routes live in `api/`.
|
|
120
|
-
- Pairing and device state live in `codex_pairing_sessions` and `codex_devices`.
|
|
121
|
-
- Existing rows without an owner remain in the database but are hidden by RLS from signed-in users.
|
|
8
|
+

|
package/bin/codex-usage.js
CHANGED
|
@@ -325,73 +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
|
-
|
|
334
|
+
try {
|
|
335
|
+
const dashboardUrl = await resolveExistingDashboardUrl(existingConfig, args)
|
|
336
|
+
let dashboardOpenState = null
|
|
334
337
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
+
if (dashboardUrl) {
|
|
339
|
+
dashboardOpenState = await openDashboard(dashboardUrl)
|
|
340
|
+
}
|
|
338
341
|
|
|
339
|
-
|
|
340
|
-
|
|
342
|
+
await syncOnce(client, existingConfig, args)
|
|
343
|
+
logDashboardOpenState(dashboardOpenState)
|
|
341
344
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
+
if (args.options.watch) {
|
|
346
|
+
await runWatchLoop(client, existingConfig, args)
|
|
347
|
+
}
|
|
345
348
|
|
|
346
|
-
|
|
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 "<dashboard-url>"\` 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 =
|
|
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
|
|
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 parseResponseBody(response)
|
|
371
|
-
if (!response.ok) {
|
|
372
|
-
throw new Error(
|
|
373
|
-
buildHttpErrorMessage(response, payload, 'Unable to connect this machine.'),
|
|
374
|
-
)
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const config = {
|
|
378
|
-
authMode: 'guest-link',
|
|
379
|
-
codexHome,
|
|
380
|
-
dashboardOrigin: siteOrigin,
|
|
381
|
-
deviceId: payload.data.deviceId,
|
|
382
|
-
deviceToken: payload.data.deviceToken,
|
|
383
|
-
label: device.label,
|
|
384
|
-
pollMs: payload.data.pollMs ?? DEFAULT_POLL_MS,
|
|
385
|
-
syncUrl: payload.data.syncUrl,
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
await writeConfig(codexHome, config)
|
|
389
|
-
await openDashboard(payload.data.dashboardUrl)
|
|
390
|
-
console.log('Dashboard opened.')
|
|
391
|
-
console.log(`Config saved to ${resolveConfigPath(codexHome)}`)
|
|
392
|
-
console.log(
|
|
393
|
-
`Next: rerun \`${NPX_COMMAND} connect --site "${siteOrigin}"\` to reopen this dashboard, or \`${NPX_COMMAND} sync --watch\` for live updates only.`,
|
|
394
|
-
)
|
|
392
|
+
const config = await startConnectFlow(client, args, codexHome, siteOrigin)
|
|
395
393
|
|
|
396
394
|
if (args.options.watch) {
|
|
397
395
|
await runWatchLoop(client, config, args)
|
|
@@ -401,6 +399,50 @@ async function runConnectCommand(args) {
|
|
|
401
399
|
}
|
|
402
400
|
}
|
|
403
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
|
+
|
|
404
446
|
async function runSyncCommand(args) {
|
|
405
447
|
const codexHome = resolveCodexHome(args.options['codex-home'])
|
|
406
448
|
const config = await readConfig(codexHome)
|
|
@@ -575,8 +617,21 @@ function buildDevicePayload(args, codexHome, fallbackLabel) {
|
|
|
575
617
|
async function openDashboard(url) {
|
|
576
618
|
try {
|
|
577
619
|
await openInBrowser(url)
|
|
620
|
+
return 'browser'
|
|
578
621
|
} catch {
|
|
579
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.')
|
|
580
635
|
}
|
|
581
636
|
}
|
|
582
637
|
|
|
@@ -620,6 +675,18 @@ function resolveSiteOrigin(configuredValue) {
|
|
|
620
675
|
return new URL(value).origin
|
|
621
676
|
}
|
|
622
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
|
+
|
|
623
690
|
function resolveCodexHome(configuredValue) {
|
|
624
691
|
const home = configuredValue ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex')
|
|
625
692
|
|
|
@@ -826,7 +893,52 @@ async function readConfig(codexHome) {
|
|
|
826
893
|
return null
|
|
827
894
|
}
|
|
828
895
|
|
|
829
|
-
|
|
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
|
+
)
|
|
830
942
|
}
|
|
831
943
|
|
|
832
944
|
async function parseResponseBody(response) {
|
|
@@ -876,6 +988,13 @@ function buildHttpErrorMessage(response, payload, fallbackMessage) {
|
|
|
876
988
|
return appendHttpContext(message, vercelError, vercelId)
|
|
877
989
|
}
|
|
878
990
|
|
|
991
|
+
function isRevokedDeviceError(error) {
|
|
992
|
+
return (
|
|
993
|
+
error instanceof Error &&
|
|
994
|
+
error.message.includes('This device is no longer authorized.')
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
|
|
879
998
|
function appendHttpContext(message, vercelError, vercelId) {
|
|
880
999
|
const context = []
|
|
881
1000
|
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-usage-dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.4",
|
|
5
5
|
"files": [
|
|
6
6
|
"README.md",
|
|
7
7
|
"bin"
|
|
8
8
|
],
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
|
-
"codex-usage": "
|
|
12
|
-
"codex-usage-dashboard": "
|
|
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
|
}
|