@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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
//
|
|
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.
|
|
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/",
|