@soulbatical/tetra-dev-toolkit 1.20.16 → 1.20.18

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.
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Tetra Doctor — checks a project for common Tetra integration issues.
5
+ *
6
+ * Scans layout.tsx, providers.tsx, globals.css, package.json, app-config.tsx
7
+ * to find missing or outdated Tetra setup before they become runtime bugs.
8
+ *
9
+ * Usage:
10
+ * tetra-doctor # Check current project
11
+ * tetra-doctor --path /path # Check specific project
12
+ * tetra-doctor --json # JSON output for CI
13
+ * tetra-doctor --fix # Auto-fix safe issues
14
+ * tetra-doctor --ci # GitHub Actions annotations
15
+ *
16
+ * Exit codes:
17
+ * 0 = all critical checks pass
18
+ * 1 = one or more critical checks fail
19
+ */
20
+
21
+ import { program } from 'commander'
22
+ import chalk from 'chalk'
23
+ import { readFileSync, existsSync } from 'fs'
24
+ import { join } from 'path'
25
+ import {
26
+ runDoctorAudit,
27
+ applyFixes,
28
+ formatDoctorReport,
29
+ formatDoctorReportJSON,
30
+ formatDoctorCIAnnotations,
31
+ } from '../lib/audits/doctor-audit.js'
32
+
33
+ /**
34
+ * Re-read the files that are subject to auto-fix, so applyFixes has
35
+ * fresh content after a potential prior fix in the same session.
36
+ */
37
+ function readFixableFiles(projectRoot, report) {
38
+ function tryRead(relativePath) {
39
+ if (!relativePath) return null
40
+ const fullPath = join(projectRoot, relativePath)
41
+ if (!existsSync(fullPath)) return null
42
+ return { path: relativePath, content: readFileSync(fullPath, 'utf-8') }
43
+ }
44
+
45
+ return {
46
+ layout: tryRead(report.files.layout),
47
+ globalsCss: tryRead(report.files.globalsCss),
48
+ }
49
+ }
50
+
51
+ program
52
+ .name('tetra-doctor')
53
+ .description('Check a Tetra project for integration issues (versions, layout, CSS tokens, etc.)')
54
+ .version('1.0.0')
55
+ .option('--path <dir>', 'Project root directory (default: cwd)')
56
+ .option('--json', 'JSON output')
57
+ .option('--fix', 'Auto-fix safe issues (suppressHydrationWarning, dark-mode.css)')
58
+ .option('--ci', 'GitHub Actions annotations for failures')
59
+ .action(async (options) => {
60
+ try {
61
+ const projectRoot = options.path || process.cwd()
62
+
63
+ if (!options.json) {
64
+ console.log(chalk.gray('\n Running checks...'))
65
+ }
66
+
67
+ let report = await runDoctorAudit(projectRoot)
68
+
69
+ // Apply fixes if requested
70
+ if (options.fix && !options.json) {
71
+ const fixableChecks = report.checks.filter(c => !c.pass && c.fixable)
72
+ if (fixableChecks.length === 0) {
73
+ console.log(chalk.gray(' Nothing to auto-fix.\n'))
74
+ } else {
75
+ const fixableFiles = readFixableFiles(projectRoot, report)
76
+ const fixes = applyFixes(projectRoot, report.checks, fixableFiles)
77
+
78
+ for (const fix of fixes) {
79
+ if (fix.success) {
80
+ console.log(chalk.green(` Fixed: ${fix.fix} in ${fix.file}`))
81
+ } else {
82
+ console.log(chalk.red(` Failed to fix ${fix.fix}: ${fix.error}`))
83
+ }
84
+ }
85
+ console.log('')
86
+
87
+ // Re-run after fixes to show updated state
88
+ report = await runDoctorAudit(projectRoot)
89
+ }
90
+ }
91
+
92
+ // Output
93
+ if (options.json) {
94
+ console.log(formatDoctorReportJSON(report))
95
+ } else {
96
+ console.log(formatDoctorReport(report, chalk, projectRoot))
97
+
98
+ if (options.ci) {
99
+ const annotations = formatDoctorCIAnnotations(report)
100
+ if (annotations) {
101
+ console.log(annotations)
102
+ }
103
+ }
104
+ }
105
+
106
+ // Exit code: fail only on critical checks
107
+ process.exit(report.summary.criticalFailed > 0 ? 1 : 0)
108
+ } catch (err) {
109
+ console.error(chalk.red(`\n ERROR: ${err.message}\n`))
110
+ if (!options.json) {
111
+ console.error(chalk.gray(` ${err.stack}`))
112
+ }
113
+ process.exit(1)
114
+ }
115
+ })
116
+
117
+ program.parse()
@@ -0,0 +1,905 @@
1
+ /**
2
+ * Tetra Doctor Audit — checks a project for common Tetra integration issues.
3
+ *
4
+ * Scans layout.tsx, providers.tsx, globals.css, package.json, app-config.tsx
5
+ * to find missing or outdated Tetra setup.
6
+ *
7
+ * Checks are grouped by severity:
8
+ * CRITICAL — blocks CI (exit 1)
9
+ * HIGH — warnings
10
+ * INFO — suggestions
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, existsSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { execSync } from 'child_process'
16
+ import { glob } from 'glob'
17
+
18
+ // ============================================================================
19
+ // FILE RESOLUTION
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Find a file by trying multiple candidate paths.
24
+ * Returns { path, content } or null if not found.
25
+ */
26
+ function findFile(projectRoot, candidates) {
27
+ for (const candidate of candidates) {
28
+ const fullPath = join(projectRoot, candidate)
29
+ if (existsSync(fullPath)) {
30
+ return { path: candidate, content: readFileSync(fullPath, 'utf-8') }
31
+ }
32
+ }
33
+ return null
34
+ }
35
+
36
+ function resolveFiles(projectRoot) {
37
+ return {
38
+ layout: findFile(projectRoot, [
39
+ 'frontend/src/app/layout.tsx',
40
+ 'src/app/layout.tsx',
41
+ 'app/layout.tsx',
42
+ ]),
43
+ providers: findFile(projectRoot, [
44
+ 'frontend/src/app/providers.tsx',
45
+ 'src/app/providers.tsx',
46
+ 'app/providers.tsx',
47
+ ]),
48
+ globalsCss: findFile(projectRoot, [
49
+ 'frontend/src/app/globals.css',
50
+ 'src/app/globals.css',
51
+ 'app/globals.css',
52
+ ]),
53
+ appConfig: findFile(projectRoot, [
54
+ 'frontend/src/lib/app-config.tsx',
55
+ 'src/lib/app-config.tsx',
56
+ 'lib/app-config.tsx',
57
+ 'frontend/src/lib/app-config.ts',
58
+ 'src/lib/app-config.ts',
59
+ ]),
60
+ frontendPackageJson: findFile(projectRoot, [
61
+ 'frontend/package.json',
62
+ 'package.json',
63
+ ]),
64
+ backendPackageJson: findFile(projectRoot, [
65
+ 'backend/package.json',
66
+ ]),
67
+ }
68
+ }
69
+
70
+ // ============================================================================
71
+ // NPM VERSION CACHE
72
+ // ============================================================================
73
+
74
+ const VERSION_CACHE_FILE = '/tmp/tetra-doctor-version-cache.json'
75
+ const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
76
+
77
+ function readVersionCache() {
78
+ try {
79
+ if (existsSync(VERSION_CACHE_FILE)) {
80
+ const raw = readFileSync(VERSION_CACHE_FILE, 'utf-8')
81
+ const cache = JSON.parse(raw)
82
+ if (Date.now() - cache.timestamp < CACHE_TTL_MS) {
83
+ return cache.versions || {}
84
+ }
85
+ }
86
+ } catch {
87
+ // ignore
88
+ }
89
+ return null
90
+ }
91
+
92
+ function writeVersionCache(versions) {
93
+ try {
94
+ writeFileSync(VERSION_CACHE_FILE, JSON.stringify({ timestamp: Date.now(), versions }))
95
+ } catch {
96
+ // ignore cache write failures
97
+ }
98
+ }
99
+
100
+ function getNpmLatestVersion(packageName) {
101
+ const cached = readVersionCache()
102
+ if (cached && cached[packageName]) {
103
+ return cached[packageName]
104
+ }
105
+
106
+ try {
107
+ const version = execSync(`npm view ${packageName} version 2>/dev/null`, {
108
+ timeout: 10000,
109
+ encoding: 'utf-8',
110
+ }).trim()
111
+
112
+ if (version) {
113
+ const newCache = { ...(cached || {}), [packageName]: version }
114
+ writeVersionCache(newCache)
115
+ }
116
+
117
+ return version || null
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Parse semver string into [major, minor, patch].
125
+ */
126
+ function parseSemver(version) {
127
+ if (!version) return [0, 0, 0]
128
+ const clean = version.replace(/^[^0-9]*/, '')
129
+ const parts = clean.split('.').map(p => parseInt(p, 10) || 0)
130
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0]
131
+ }
132
+
133
+ /**
134
+ * Returns true if installed is at least the same major+minor as latest.
135
+ */
136
+ function isVersionRecent(installed, latest) {
137
+ const [iMaj, iMin] = parseSemver(installed)
138
+ const [lMaj, lMin] = parseSemver(latest)
139
+ if (iMaj !== lMaj) return iMaj > lMaj
140
+ return iMin >= lMin
141
+ }
142
+
143
+ /**
144
+ * Extract installed version of a package from a package.json content string.
145
+ */
146
+ function getInstalledVersion(packageJsonContent, packageName) {
147
+ if (!packageJsonContent) return null
148
+ try {
149
+ const pkg = JSON.parse(packageJsonContent)
150
+ const deps = {
151
+ ...(pkg.dependencies || {}),
152
+ ...(pkg.devDependencies || {}),
153
+ }
154
+ const raw = deps[packageName]
155
+ if (!raw) return null
156
+ return raw.replace(/^[^0-9]*/, '')
157
+ } catch {
158
+ return null
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Check if a package exists in node_modules.
164
+ */
165
+ function isInNodeModules(projectRoot, packageName) {
166
+ const candidates = [
167
+ join(projectRoot, 'frontend/node_modules', packageName, 'package.json'),
168
+ join(projectRoot, 'node_modules', packageName, 'package.json'),
169
+ ]
170
+ return candidates.some(p => existsSync(p))
171
+ }
172
+
173
+ function getNodeModulesVersion(projectRoot, packageName) {
174
+ const candidates = [
175
+ join(projectRoot, 'frontend/node_modules', packageName, 'package.json'),
176
+ join(projectRoot, 'node_modules', packageName, 'package.json'),
177
+ ]
178
+ for (const p of candidates) {
179
+ if (existsSync(p)) {
180
+ try {
181
+ return JSON.parse(readFileSync(p, 'utf-8')).version || null
182
+ } catch { /* ignore */ }
183
+ }
184
+ }
185
+ return null
186
+ }
187
+
188
+ // ============================================================================
189
+ // INDIVIDUAL CHECKS
190
+ // ============================================================================
191
+
192
+ /**
193
+ * CRITICAL 1: layout.tsx <html> must have suppressHydrationWarning.
194
+ */
195
+ function checkSuppressHydrationWarning(files) {
196
+ if (!files.layout) {
197
+ return {
198
+ id: 'suppressHydrationWarning',
199
+ severity: 'critical',
200
+ label: 'suppressHydrationWarning',
201
+ pass: false,
202
+ detail: 'layout.tsx not found',
203
+ fixable: false,
204
+ }
205
+ }
206
+
207
+ // If TetraRootLayout is used, it provides suppressHydrationWarning implicitly
208
+ if (/TetraRootLayout/.test(files.layout.content)) {
209
+ return {
210
+ id: 'suppressHydrationWarning',
211
+ severity: 'critical',
212
+ label: 'suppressHydrationWarning',
213
+ pass: true,
214
+ detail: `provided by TetraRootLayout in ${files.layout.path}`,
215
+ fixable: false,
216
+ }
217
+ }
218
+
219
+ const hasHtmlTag = /<html[\s>]/.test(files.layout.content)
220
+ if (!hasHtmlTag) {
221
+ return {
222
+ id: 'suppressHydrationWarning',
223
+ severity: 'critical',
224
+ label: 'suppressHydrationWarning',
225
+ pass: false,
226
+ detail: `no <html> tag found in ${files.layout.path}`,
227
+ fixable: false,
228
+ }
229
+ }
230
+
231
+ const hasSuppressWarning = /<html[^>]*suppressHydrationWarning/.test(files.layout.content)
232
+ return {
233
+ id: 'suppressHydrationWarning',
234
+ severity: 'critical',
235
+ label: 'suppressHydrationWarning',
236
+ pass: hasSuppressWarning,
237
+ detail: hasSuppressWarning
238
+ ? `present in ${files.layout.path}`
239
+ : `missing on <html> tag in ${files.layout.path}`,
240
+ fixable: !hasSuppressWarning,
241
+ fixFile: files.layout.path,
242
+ }
243
+ }
244
+
245
+ /**
246
+ * CRITICAL 2: ThemeProvider must be imported from tetra-ui in providers.tsx or layout.tsx.
247
+ */
248
+ function checkThemeProvider(files) {
249
+ const searchFiles = [files.providers, files.layout].filter(Boolean)
250
+
251
+ for (const file of searchFiles) {
252
+ // TetraRootLayout includes ThemeProvider
253
+ if (/TetraRootLayout/.test(file.content)) {
254
+ return {
255
+ id: 'themeProvider',
256
+ severity: 'critical',
257
+ label: 'ThemeProvider',
258
+ pass: true,
259
+ detail: `provided by TetraRootLayout in ${file.path}`,
260
+ fixable: false,
261
+ }
262
+ }
263
+
264
+ const importsFromTetraUi = /from ['"]@soulbatical\/tetra-ui['"]/.test(file.content) ||
265
+ /from ['"]@soulbatical\/tetra-ui\//.test(file.content)
266
+ const mentionsThemeProvider = /ThemeProvider/.test(file.content)
267
+
268
+ if (importsFromTetraUi && mentionsThemeProvider) {
269
+ return {
270
+ id: 'themeProvider',
271
+ severity: 'critical',
272
+ label: 'ThemeProvider',
273
+ pass: true,
274
+ detail: `imported in ${file.path}`,
275
+ fixable: false,
276
+ }
277
+ }
278
+ }
279
+
280
+ return {
281
+ id: 'themeProvider',
282
+ severity: 'critical',
283
+ label: 'ThemeProvider',
284
+ pass: false,
285
+ detail: 'not found in providers.tsx or layout.tsx',
286
+ fixable: false,
287
+ }
288
+ }
289
+
290
+ /**
291
+ * CRITICAL 3: tetra-ui version must be >= latest minor.
292
+ */
293
+ function checkTetraUiVersion(files) {
294
+ const pkgContent = files.frontendPackageJson?.content
295
+ const installed = getInstalledVersion(pkgContent, '@soulbatical/tetra-ui')
296
+
297
+ if (!installed) {
298
+ return {
299
+ id: 'tetraUiVersion',
300
+ severity: 'critical',
301
+ label: 'tetra-ui version',
302
+ pass: false,
303
+ detail: '@soulbatical/tetra-ui not found in package.json',
304
+ fixable: false,
305
+ }
306
+ }
307
+
308
+ const latest = getNpmLatestVersion('@soulbatical/tetra-ui')
309
+ if (!latest) {
310
+ return {
311
+ id: 'tetraUiVersion',
312
+ severity: 'critical',
313
+ label: 'tetra-ui version',
314
+ pass: true,
315
+ detail: `${installed} installed (npm registry unreachable)`,
316
+ fixable: false,
317
+ }
318
+ }
319
+
320
+ const isRecent = isVersionRecent(installed, latest)
321
+ return {
322
+ id: 'tetraUiVersion',
323
+ severity: 'critical',
324
+ label: 'tetra-ui version',
325
+ pass: isRecent,
326
+ detail: isRecent
327
+ ? `${installed} installed (latest: ${latest})`
328
+ : `${installed} installed, ${latest} available`,
329
+ fixable: false,
330
+ installed,
331
+ latest,
332
+ }
333
+ }
334
+
335
+ /**
336
+ * CRITICAL 4: next-themes must be in node_modules.
337
+ */
338
+ function checkNextThemes(projectRoot) {
339
+ const installed = isInNodeModules(projectRoot, 'next-themes')
340
+ const version = installed ? getNodeModulesVersion(projectRoot, 'next-themes') : null
341
+
342
+ return {
343
+ id: 'nextThemes',
344
+ severity: 'critical',
345
+ label: 'next-themes',
346
+ pass: installed,
347
+ detail: installed
348
+ ? `${version || 'unknown version'} installed`
349
+ : 'not found in node_modules (required peerDep of tetra-ui)',
350
+ fixable: false,
351
+ }
352
+ }
353
+
354
+ /**
355
+ * CRITICAL 5: globals.css must import tokens.css.
356
+ */
357
+ function checkCssTokens(files) {
358
+ if (!files.globalsCss) {
359
+ return {
360
+ id: 'cssTokens',
361
+ severity: 'critical',
362
+ label: 'CSS tokens',
363
+ pass: false,
364
+ detail: 'globals.css not found',
365
+ fixable: false,
366
+ }
367
+ }
368
+
369
+ const hasTokens =
370
+ /tetra-ui.*tokens\.css/.test(files.globalsCss.content) ||
371
+ /@import.*tokens\.css/.test(files.globalsCss.content)
372
+
373
+ return {
374
+ id: 'cssTokens',
375
+ severity: 'critical',
376
+ label: 'CSS tokens',
377
+ pass: hasTokens,
378
+ detail: hasTokens
379
+ ? `tokens.css imported in ${files.globalsCss.path}`
380
+ : `tokens.css not imported in ${files.globalsCss.path}`,
381
+ fixable: false,
382
+ }
383
+ }
384
+
385
+ /**
386
+ * CRITICAL 6: globals.css must import dark-mode.css OR define @custom-variant dark.
387
+ */
388
+ function checkDarkMode(files) {
389
+ if (!files.globalsCss) {
390
+ return {
391
+ id: 'darkMode',
392
+ severity: 'critical',
393
+ label: 'Dark mode',
394
+ pass: false,
395
+ detail: 'globals.css not found',
396
+ fixable: false,
397
+ }
398
+ }
399
+
400
+ const hasDarkModeCss =
401
+ /tetra-ui.*dark-mode\.css/.test(files.globalsCss.content) ||
402
+ /@import.*dark-mode\.css/.test(files.globalsCss.content)
403
+ const hasCustomVariant = /@custom-variant\s+dark/.test(files.globalsCss.content)
404
+ const pass = hasDarkModeCss || hasCustomVariant
405
+
406
+ return {
407
+ id: 'darkMode',
408
+ severity: 'critical',
409
+ label: 'Dark mode',
410
+ pass,
411
+ detail: pass
412
+ ? hasDarkModeCss
413
+ ? `dark-mode.css imported in ${files.globalsCss.path}`
414
+ : `@custom-variant dark found in ${files.globalsCss.path}`
415
+ : `dark-mode.css not imported and no @custom-variant dark in ${files.globalsCss.path}`,
416
+ fixable: !pass,
417
+ fixFile: files.globalsCss?.path,
418
+ }
419
+ }
420
+
421
+ /**
422
+ * HIGH 7: tetra-core version should be recent.
423
+ */
424
+ function checkTetraCoreVersion(files) {
425
+ const pkgContent = files.backendPackageJson?.content
426
+ const installed = getInstalledVersion(pkgContent, '@soulbatical/tetra-core')
427
+
428
+ if (!installed) {
429
+ return {
430
+ id: 'tetraCoreVersion',
431
+ severity: 'high',
432
+ label: 'tetra-core version',
433
+ pass: true,
434
+ detail: 'backend/package.json not found or tetra-core not used',
435
+ fixable: false,
436
+ }
437
+ }
438
+
439
+ const latest = getNpmLatestVersion('@soulbatical/tetra-core')
440
+ if (!latest) {
441
+ return {
442
+ id: 'tetraCoreVersion',
443
+ severity: 'high',
444
+ label: 'tetra-core version',
445
+ pass: true,
446
+ detail: `${installed} installed (npm registry unreachable)`,
447
+ fixable: false,
448
+ }
449
+ }
450
+
451
+ const isRecent = isVersionRecent(installed, latest)
452
+ return {
453
+ id: 'tetraCoreVersion',
454
+ severity: 'high',
455
+ label: 'tetra-core version',
456
+ pass: isRecent,
457
+ detail: isRecent
458
+ ? `${installed} (latest: ${latest})`
459
+ : `${installed} installed, ${latest} available`,
460
+ fixable: false,
461
+ installed,
462
+ latest,
463
+ }
464
+ }
465
+
466
+ /**
467
+ * HIGH 8: dashboard layout should use AppShell from tetra-ui.
468
+ */
469
+ async function checkAppShellUsage(projectRoot) {
470
+ const knownCandidates = [
471
+ 'frontend/src/app/(dashboard)/layout.tsx',
472
+ 'src/app/(dashboard)/layout.tsx',
473
+ 'frontend/src/app/(admin)/layout.tsx',
474
+ 'src/app/(admin)/layout.tsx',
475
+ ]
476
+
477
+ for (const candidate of knownCandidates) {
478
+ const fullPath = join(projectRoot, candidate)
479
+ if (existsSync(fullPath)) {
480
+ const content = readFileSync(fullPath, 'utf-8')
481
+ const usesAppShell = /AppShell/.test(content)
482
+ return {
483
+ id: 'appShellUsage',
484
+ severity: 'high',
485
+ label: 'AppShell usage',
486
+ pass: usesAppShell,
487
+ detail: usesAppShell
488
+ ? `found in ${candidate}`
489
+ : `${candidate} exists but does not use AppShell`,
490
+ fixable: false,
491
+ }
492
+ }
493
+ }
494
+
495
+ // Fallback: scan all layout files for one that looks like a dashboard layout
496
+ try {
497
+ const layoutFiles = await glob('**/layout.tsx', {
498
+ cwd: projectRoot,
499
+ ignore: ['**/node_modules/**'],
500
+ })
501
+ const dashboardLayout = layoutFiles.find(f =>
502
+ f.includes('dashboard') || f.includes('admin')
503
+ )
504
+ if (dashboardLayout) {
505
+ const content = readFileSync(join(projectRoot, dashboardLayout), 'utf-8')
506
+ const usesAppShell = /AppShell/.test(content)
507
+ return {
508
+ id: 'appShellUsage',
509
+ severity: 'high',
510
+ label: 'AppShell usage',
511
+ pass: usesAppShell,
512
+ detail: usesAppShell
513
+ ? `found in ${dashboardLayout}`
514
+ : `${dashboardLayout} does not use AppShell`,
515
+ fixable: false,
516
+ }
517
+ }
518
+ } catch { /* ignore glob failures */ }
519
+
520
+ return {
521
+ id: 'appShellUsage',
522
+ severity: 'high',
523
+ label: 'AppShell usage',
524
+ pass: true,
525
+ detail: 'no dashboard/admin layout found',
526
+ fixable: false,
527
+ }
528
+ }
529
+
530
+ /**
531
+ * HIGH 9: app-config.tsx must exist with TetraAppConfig.
532
+ */
533
+ function checkAppConfig(files) {
534
+ if (!files.appConfig) {
535
+ return {
536
+ id: 'appConfig',
537
+ severity: 'high',
538
+ label: 'app-config.tsx',
539
+ pass: false,
540
+ detail: 'app-config.tsx not found in frontend/src/lib/',
541
+ fixable: false,
542
+ }
543
+ }
544
+
545
+ const hasTetraAppConfig = /TetraAppConfig/.test(files.appConfig.content)
546
+ return {
547
+ id: 'appConfig',
548
+ severity: 'high',
549
+ label: 'app-config.tsx',
550
+ pass: hasTetraAppConfig,
551
+ detail: hasTetraAppConfig
552
+ ? `TetraAppConfig found in ${files.appConfig.path}`
553
+ : `${files.appConfig.path} exists but does not use TetraAppConfig`,
554
+ fixable: false,
555
+ }
556
+ }
557
+
558
+ /**
559
+ * HIGH 10: nav items should have shortcuts property.
560
+ */
561
+ function checkKeyboardShortcuts(files) {
562
+ if (!files.appConfig) {
563
+ return {
564
+ id: 'keyboardShortcuts',
565
+ severity: 'high',
566
+ label: 'Keyboard shortcuts',
567
+ pass: true,
568
+ detail: 'app-config.tsx not found, skipping',
569
+ fixable: false,
570
+ }
571
+ }
572
+
573
+ const content = files.appConfig.content
574
+ const navItemMatches = content.match(/href\s*:/g) || []
575
+ const shortcutMatches = content.match(/shortcut\s*:/g) || []
576
+
577
+ const total = navItemMatches.length
578
+ const withShortcuts = shortcutMatches.length
579
+ const missing = total - withShortcuts
580
+
581
+ if (total === 0) {
582
+ return {
583
+ id: 'keyboardShortcuts',
584
+ severity: 'high',
585
+ label: 'Keyboard shortcuts',
586
+ pass: true,
587
+ detail: 'no nav items found in app-config.tsx',
588
+ fixable: false,
589
+ }
590
+ }
591
+
592
+ const pass = missing === 0
593
+ return {
594
+ id: 'keyboardShortcuts',
595
+ severity: 'high',
596
+ label: 'Keyboard shortcuts',
597
+ pass,
598
+ detail: pass
599
+ ? `all ${total} nav items have shortcuts`
600
+ : `${withShortcuts}/${total} nav items have shortcuts (${missing} missing)`,
601
+ fixable: false,
602
+ navTotal: total,
603
+ navWithShortcuts: withShortcuts,
604
+ }
605
+ }
606
+
607
+ /**
608
+ * INFO 11: Suggest TetraRootLayout if layout.tsx is manually built.
609
+ */
610
+ function checkTetraRootLayoutAdoption(files) {
611
+ if (!files.layout) {
612
+ return {
613
+ id: 'tetraRootLayout',
614
+ severity: 'info',
615
+ label: 'TetraRootLayout',
616
+ pass: true,
617
+ detail: 'layout.tsx not found',
618
+ suggestion: null,
619
+ }
620
+ }
621
+
622
+ const usesTetraRootLayout = /TetraRootLayout/.test(files.layout.content)
623
+ const hasManualHtml = /<html[\s>]/.test(files.layout.content)
624
+
625
+ return {
626
+ id: 'tetraRootLayout',
627
+ severity: 'info',
628
+ label: 'TetraRootLayout',
629
+ pass: usesTetraRootLayout,
630
+ detail: usesTetraRootLayout
631
+ ? `TetraRootLayout used in ${files.layout.path}`
632
+ : null,
633
+ suggestion: !usesTetraRootLayout && hasManualHtml
634
+ ? 'Consider using TetraRootLayout instead of manual layout.tsx (guarantees suppressHydrationWarning + ThemeProvider)'
635
+ : null,
636
+ }
637
+ }
638
+
639
+ /**
640
+ * INFO 12: Count pages using FeaturePage vs ad-hoc.
641
+ */
642
+ async function checkFeaturePageAdoption(projectRoot) {
643
+ let pageFiles = []
644
+ try {
645
+ pageFiles = await glob('**/page.tsx', {
646
+ cwd: projectRoot,
647
+ ignore: ['**/node_modules/**'],
648
+ })
649
+ } catch {
650
+ return {
651
+ id: 'featurePageAdoption',
652
+ severity: 'info',
653
+ label: 'FeaturePage adoption',
654
+ pass: true,
655
+ detail: null,
656
+ suggestion: null,
657
+ }
658
+ }
659
+
660
+ let total = 0
661
+ let usingFeaturePage = 0
662
+
663
+ for (const file of pageFiles) {
664
+ const fullPath = join(projectRoot, file)
665
+ try {
666
+ const content = readFileSync(fullPath, 'utf-8')
667
+ total++
668
+ if (/FeaturePage/.test(content)) {
669
+ usingFeaturePage++
670
+ }
671
+ } catch { /* ignore */ }
672
+ }
673
+
674
+ const remaining = total - usingFeaturePage
675
+ return {
676
+ id: 'featurePageAdoption',
677
+ severity: 'info',
678
+ label: 'FeaturePage adoption',
679
+ pass: remaining === 0,
680
+ detail: `${usingFeaturePage}/${total} pages use FeaturePage`,
681
+ suggestion: remaining > 0 && usingFeaturePage > 0
682
+ ? `${usingFeaturePage}/${total} pages use FeaturePage (${remaining} remaining)`
683
+ : null,
684
+ total,
685
+ usingFeaturePage,
686
+ }
687
+ }
688
+
689
+ // ============================================================================
690
+ // AUTO-FIX
691
+ // ============================================================================
692
+
693
+ /**
694
+ * Apply safe auto-fixes to the project.
695
+ * Returns list of { fix, file, success, error? } applied.
696
+ */
697
+ export function applyFixes(projectRoot, checks, files) {
698
+ const applied = []
699
+
700
+ // Fix 1: Add suppressHydrationWarning to <html> tag
701
+ const suppressCheck = checks.find(c => c.id === 'suppressHydrationWarning')
702
+ if (suppressCheck && !suppressCheck.pass && suppressCheck.fixable && files.layout) {
703
+ const fullPath = join(projectRoot, files.layout.path)
704
+ try {
705
+ const original = readFileSync(fullPath, 'utf-8')
706
+ // Replace <html> or <html prop with <html suppressHydrationWarning prop / <html suppressHydrationWarning>
707
+ const fixed = original
708
+ .replace(/<html\s+([^>]*)>/, '<html suppressHydrationWarning $1>')
709
+ .replace(/<html>/, '<html suppressHydrationWarning>')
710
+ if (fixed !== original) {
711
+ writeFileSync(fullPath, fixed)
712
+ applied.push({ fix: 'suppressHydrationWarning', file: files.layout.path, success: true })
713
+ }
714
+ } catch (err) {
715
+ applied.push({ fix: 'suppressHydrationWarning', file: files.layout.path, success: false, error: err.message })
716
+ }
717
+ }
718
+
719
+ // Fix 2: Add dark-mode.css import to globals.css
720
+ const darkModeCheck = checks.find(c => c.id === 'darkMode')
721
+ if (darkModeCheck && !darkModeCheck.pass && darkModeCheck.fixable && files.globalsCss) {
722
+ const fullPath = join(projectRoot, files.globalsCss.path)
723
+ try {
724
+ const original = readFileSync(fullPath, 'utf-8')
725
+ const darkModeImport = '@import "@soulbatical/tetra-ui/styles/dark-mode.css";'
726
+
727
+ // Insert after tokens.css import if it exists, otherwise prepend
728
+ const tokensImportMatch = original.match(/(@import[^\n]*tokens\.css[^\n]*\n?)/)
729
+ let fixed
730
+ if (tokensImportMatch) {
731
+ fixed = original.replace(
732
+ tokensImportMatch[0],
733
+ tokensImportMatch[0] + darkModeImport + '\n'
734
+ )
735
+ } else {
736
+ fixed = darkModeImport + '\n' + original
737
+ }
738
+
739
+ if (fixed !== original) {
740
+ writeFileSync(fullPath, fixed)
741
+ applied.push({ fix: 'darkMode', file: files.globalsCss.path, success: true })
742
+ }
743
+ } catch (err) {
744
+ applied.push({ fix: 'darkMode', file: files.globalsCss.path, success: false, error: err.message })
745
+ }
746
+ }
747
+
748
+ return applied
749
+ }
750
+
751
+ // ============================================================================
752
+ // MAIN AUDIT
753
+ // ============================================================================
754
+
755
+ /**
756
+ * Run the full doctor audit.
757
+ */
758
+ export async function runDoctorAudit(projectRoot) {
759
+ const files = resolveFiles(projectRoot)
760
+
761
+ const [appShellCheck, featurePageCheck] = await Promise.all([
762
+ checkAppShellUsage(projectRoot),
763
+ checkFeaturePageAdoption(projectRoot),
764
+ ])
765
+
766
+ const checks = [
767
+ // CRITICAL
768
+ checkSuppressHydrationWarning(files),
769
+ checkThemeProvider(files),
770
+ checkTetraUiVersion(files),
771
+ checkNextThemes(projectRoot),
772
+ checkCssTokens(files),
773
+ checkDarkMode(files),
774
+ // HIGH
775
+ checkTetraCoreVersion(files),
776
+ appShellCheck,
777
+ checkAppConfig(files),
778
+ checkKeyboardShortcuts(files),
779
+ // INFO
780
+ checkTetraRootLayoutAdoption(files),
781
+ featurePageCheck,
782
+ ]
783
+
784
+ const criticalChecks = checks.filter(c => c.severity === 'critical')
785
+ const highChecks = checks.filter(c => c.severity === 'high')
786
+ const infoChecks = checks.filter(c => c.severity === 'info')
787
+
788
+ const criticalFailed = criticalChecks.filter(c => !c.pass).length
789
+ const highFailed = highChecks.filter(c => !c.pass).length
790
+ const totalChecks = criticalChecks.length + highChecks.length
791
+ const totalPassed = totalChecks - criticalFailed - highFailed
792
+ const score = totalChecks > 0 ? Math.round((totalPassed / totalChecks) * 100) : 100
793
+
794
+ return {
795
+ checks,
796
+ criticalChecks,
797
+ highChecks,
798
+ infoChecks,
799
+ files: {
800
+ layout: files.layout?.path || null,
801
+ providers: files.providers?.path || null,
802
+ globalsCss: files.globalsCss?.path || null,
803
+ appConfig: files.appConfig?.path || null,
804
+ },
805
+ summary: {
806
+ score,
807
+ criticalFailed,
808
+ highFailed,
809
+ totalChecks,
810
+ totalPassed,
811
+ },
812
+ }
813
+ }
814
+
815
+ // ============================================================================
816
+ // FORMATTERS
817
+ // ============================================================================
818
+
819
+ /**
820
+ * Format report as pretty terminal output.
821
+ */
822
+ export function formatDoctorReport(report, chalk, projectRoot) {
823
+ const lines = []
824
+ const { criticalChecks, highChecks, infoChecks, summary } = report
825
+
826
+ const projectName = projectRoot.split('/').pop()
827
+
828
+ lines.push('')
829
+ lines.push(chalk.cyan.bold(' Tetra Doctor'))
830
+ lines.push(chalk.cyan(' ' + '='.repeat(15)))
831
+ lines.push('')
832
+ lines.push(chalk.gray(` Checking ${projectName}...`))
833
+ lines.push('')
834
+
835
+ // CRITICAL
836
+ lines.push(chalk.white.bold(' CRITICAL'))
837
+ for (const check of criticalChecks) {
838
+ const icon = check.pass ? chalk.green(' ✅') : chalk.red(' ❌')
839
+ const label = check.label.padEnd(30)
840
+ lines.push(`${icon} ${chalk.white(label)} ${chalk.gray(check.detail || '')}`)
841
+ }
842
+ lines.push('')
843
+
844
+ // HIGH
845
+ lines.push(chalk.white.bold(' HIGH'))
846
+ for (const check of highChecks) {
847
+ const icon = check.pass ? chalk.green(' ✅') : chalk.yellow(' ⚠️ ')
848
+ const label = check.label.padEnd(30)
849
+ lines.push(`${icon} ${chalk.white(label)} ${chalk.gray(check.detail || '')}`)
850
+ }
851
+ lines.push('')
852
+
853
+ // INFO — only show if there are suggestions
854
+ const infoWithSuggestions = infoChecks.filter(c => c.suggestion || c.detail)
855
+ if (infoWithSuggestions.length > 0) {
856
+ lines.push(chalk.white.bold(' INFO'))
857
+ for (const check of infoChecks) {
858
+ const message = check.suggestion || check.detail
859
+ if (message) {
860
+ lines.push(` ${chalk.blue('💡')} ${chalk.gray(message)}`)
861
+ }
862
+ }
863
+ lines.push('')
864
+ }
865
+
866
+ // Summary
867
+ lines.push(chalk.gray(' ' + '─'.repeat(39)))
868
+ const scoreColor = summary.score >= 90
869
+ ? chalk.green
870
+ : summary.score >= 70
871
+ ? chalk.yellow
872
+ : chalk.red
873
+ lines.push(
874
+ scoreColor.bold(` Score: ${summary.score}%`) +
875
+ chalk.gray(` — ${summary.criticalFailed} critical, ${summary.highFailed} warning`)
876
+ )
877
+ lines.push('')
878
+
879
+ return lines.join('\n')
880
+ }
881
+
882
+ /**
883
+ * Format report as JSON.
884
+ */
885
+ export function formatDoctorReportJSON(report) {
886
+ return JSON.stringify(report, null, 2)
887
+ }
888
+
889
+ /**
890
+ * Format GitHub Actions annotations for failures.
891
+ */
892
+ export function formatDoctorCIAnnotations(report) {
893
+ const annotations = []
894
+ for (const check of report.criticalChecks) {
895
+ if (!check.pass) {
896
+ annotations.push(`::error title=Tetra Doctor — ${check.label}::${check.detail}`)
897
+ }
898
+ }
899
+ for (const check of report.highChecks) {
900
+ if (!check.pass) {
901
+ annotations.push(`::warning title=Tetra Doctor — ${check.label}::${check.detail}`)
902
+ }
903
+ }
904
+ return annotations.join('\n')
905
+ }
@@ -103,26 +103,38 @@ function hasOrgAdminMiddleware(content) {
103
103
  }
104
104
 
105
105
  /**
106
- * Check if admin routes are protected by a RouteManager group-level middleware.
107
- * Many projects apply auth middleware at the route group level (e.g., all /api/admin/* routes)
108
- * rather than in individual route files.
106
+ * Check if admin routes are protected by group-level middleware.
107
+ *
108
+ * Supports two patterns:
109
+ * 1. RouteManager: explicit authenticateToken in routes/index.ts or RouteManager.ts
110
+ * 2. createApp declarative routes: `access: 'admin'` in index.ts/app.ts
111
+ * → createApp auto-injects authenticateToken + requireOrgAdmin for access='admin'
109
112
  */
110
113
  function hasRouteManagerGroupAuth(projectRoot) {
111
114
  const candidates = [
112
115
  join(projectRoot, 'backend/src/core/RouteManager.ts'),
113
116
  join(projectRoot, 'src/core/RouteManager.ts'),
114
117
  join(projectRoot, 'backend/src/routes/index.ts'),
115
- join(projectRoot, 'src/routes/index.ts')
118
+ join(projectRoot, 'src/routes/index.ts'),
119
+ join(projectRoot, 'backend/src/index.ts'),
120
+ join(projectRoot, 'src/index.ts'),
121
+ join(projectRoot, 'backend/src/app.ts'),
122
+ join(projectRoot, 'src/app.ts')
116
123
  ]
117
124
 
118
125
  for (const file of candidates) {
119
126
  if (!existsSync(file)) continue
120
127
  try {
121
128
  const content = readFileSync(file, 'utf-8')
122
- // Check for group middleware pattern: prefix '/api/admin' with authenticateToken
129
+ // Pattern 1: RouteManager with explicit authenticateToken for /api/admin
123
130
  if (/\/api\/admin/.test(content) && /authenticateToken/.test(content)) {
124
131
  return true
125
132
  }
133
+ // Pattern 2: createApp declarative routes — access: 'admin' auto-injects auth
134
+ // createApp guarantees authenticateToken + requireOrgAdmin for access='admin'
135
+ if (/createApp/.test(content) && /access:\s*['"]admin['"]/.test(content)) {
136
+ return true
137
+ }
126
138
  } catch { /* skip */ }
127
139
  }
128
140
  return false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.16",
3
+ "version": "1.20.18",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -42,7 +42,8 @@
42
42
  "tetra-init-tests": "./bin/tetra-init-tests.js",
43
43
  "tetra-test-audit": "./bin/tetra-test-audit.js",
44
44
  "tetra-check-pages": "./bin/tetra-check-pages.js",
45
- "tetra-check-views": "./bin/tetra-check-views.js"
45
+ "tetra-check-views": "./bin/tetra-check-views.js",
46
+ "tetra-doctor": "./bin/tetra-doctor.js"
46
47
  },
47
48
  "files": [
48
49
  "bin/",