Skip to content

Commit 6c142ed

Browse files
committed
feat(nuxi): add --verbose and --json flags to doctor
1 parent 517377c commit 6c142ed

File tree

2 files changed

+300
-111
lines changed

2 files changed

+300
-111
lines changed

packages/nuxi/src/commands/doctor.ts

Lines changed: 107 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,33 +13,50 @@ import { isBun, isDeno } from 'std-env'
1313
import { loadKit, tryResolveNuxt } from '../utils/kit'
1414
import { cwdArgs, legacyRootDirArgs, logLevelArgs } from './_shared'
1515

16-
// Types for the hook-based architecture (will move to @nuxt/schema)
1716
interface DoctorCheck {
17+
// Required
1818
name: string
1919
status: 'success' | 'warning' | 'error'
2020
message: string
21-
source?: string // module name, e.g. "@nuxt/a11y"
22-
details?: string[] // list of issues/details to display
21+
22+
// Optional - identity/origin
23+
id?: string // programmatic code: "MISSING_PEER_DEP"
24+
source?: string // module name: "@nuxt/ui"
25+
26+
// Optional - verbose fields
27+
details?: string | string[]
28+
suggestion?: string
29+
url?: string
30+
31+
// Optional - programmatic
32+
data?: Record<string, unknown>
2333
}
2434

2535
interface DoctorCheckContext {
2636
addCheck: (check: DoctorCheck) => void
2737
nuxt: Nuxt
2838
}
2939

30-
// Augment NuxtHooks for the doctor:check hook (will be moved to @nuxt/schema)
3140
declare module '@nuxt/schema' {
3241
interface NuxtHooks {
3342
'doctor:check': (ctx: DoctorCheckContext) => void | Promise<void>
3443
}
3544
}
3645

46+
const plural = (n: number) => n === 1 ? '' : 's'
47+
3748
async function resolveNuxtVersion(cwd: string): Promise<string | undefined> {
3849
const nuxtPath = tryResolveNuxt(cwd)
3950
for (const pkg of ['nuxt', 'nuxt-nightly', 'nuxt-edge', 'nuxt3']) {
40-
const pkgJson = await readPackageJSON(pkg, { url: nuxtPath || cwd }).catch(() => null)
41-
if (pkgJson?.version) {
42-
return pkgJson.version
51+
try {
52+
const pkgJson = await readPackageJSON(pkg, { url: nuxtPath || cwd })
53+
if (pkgJson?.version)
54+
return pkgJson.version
55+
}
56+
catch (err: any) {
57+
// Ignore "not found" errors, log unexpected ones
58+
if (err?.code !== 'ERR_MODULE_NOT_FOUND' && err?.code !== 'ENOENT' && !err?.message?.includes('Cannot find'))
59+
log.warn(`Failed to read ${pkg} version: ${err?.message || err}`)
4360
}
4461
}
4562
}
@@ -53,11 +70,20 @@ export default defineCommand({
5370
...cwdArgs,
5471
...legacyRootDirArgs,
5572
...logLevelArgs,
73+
verbose: {
74+
type: 'boolean',
75+
description: 'Show details, suggestions, and URLs',
76+
},
77+
json: {
78+
type: 'boolean',
79+
description: 'Output results as JSON',
80+
},
5681
},
5782
async run(ctx) {
5883
const cwd = resolve(ctx.args.cwd || ctx.args.rootDir)
5984

60-
intro(colors.cyan('Running diagnostics...'))
85+
if (!ctx.args.json)
86+
intro(colors.cyan('Running diagnostics...'))
6187

6288
const { loadNuxt } = await loadKit(cwd)
6389

@@ -72,10 +98,15 @@ export default defineCommand({
7298
})
7399
}
74100
catch (err) {
75-
log.error(colors.red(`Failed to load Nuxt: ${err instanceof Error ? err.message : String(err)}`))
76-
outro(colors.red('Diagnostics failed'))
77-
process.exit(1)
78-
return // unreachable but needed for type narrowing in tests
101+
if (ctx.args.json) {
102+
// eslint-disable-next-line no-console
103+
console.log(JSON.stringify([{ name: 'Nuxt', status: 'error', message: `Failed to load Nuxt: ${err instanceof Error ? err.message : String(err)}` }]))
104+
}
105+
else {
106+
log.error(colors.red(`Failed to load Nuxt: ${err instanceof Error ? err.message : String(err)}`))
107+
outro(colors.red('Diagnostics failed'))
108+
}
109+
return process.exit(1)
79110
}
80111

81112
const checks: DoctorCheck[] = []
@@ -91,7 +122,7 @@ export default defineCommand({
91122
})
92123

93124
// 3. Display results
94-
displayResults(checks)
125+
displayResults(checks, { verbose: ctx.args.verbose, json: ctx.args.json })
95126
}
96127
finally {
97128
await nuxt.close()
@@ -100,28 +131,33 @@ export default defineCommand({
100131
const hasErrors = checks.some(c => c.status === 'error')
101132
const hasWarnings = checks.some(c => c.status === 'warning')
102133

103-
if (hasErrors) {
104-
outro(colors.red('Diagnostics complete with errors'))
105-
process.exit(1)
106-
}
107-
else if (hasWarnings) {
108-
outro(colors.yellow('Diagnostics complete with warnings'))
109-
}
110-
else {
111-
outro(colors.green('All checks passed!'))
134+
if (!ctx.args.json) {
135+
if (hasErrors)
136+
outro(colors.red('Diagnostics complete with errors'))
137+
else if (hasWarnings)
138+
outro(colors.yellow('Diagnostics complete with warnings'))
139+
else
140+
outro(colors.green('All checks passed!'))
112141
}
142+
143+
if (hasErrors)
144+
process.exit(1)
113145
},
114146
})
115147

116148
async function runCoreChecks(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise<void> {
117-
// Version check
118-
await checkVersions(checks, cwd)
119-
120-
// Config validation
121-
checkConfig(checks, nuxt)
149+
const runCheck = async (name: string, fn: () => void | Promise<void>) => {
150+
try {
151+
await fn()
152+
}
153+
catch (err) {
154+
checks.push({ name, status: 'error', message: `Check failed: ${err instanceof Error ? err.message : String(err)}` })
155+
}
156+
}
122157

123-
// Module compatibility
124-
await checkModuleCompat(checks, nuxt, cwd)
158+
await runCheck('Versions', () => checkVersions(checks, cwd))
159+
await runCheck('Config', () => checkConfig(checks, nuxt))
160+
await runCheck('Modules', () => checkModuleCompat(checks, nuxt, cwd))
125161
}
126162

127163
async function checkVersions(checks: DoctorCheck[], cwd: string): Promise<void> {
@@ -137,14 +173,14 @@ async function checkVersions(checks: DoctorCheck[], cwd: string): Promise<void>
137173

138174
// Check Node.js version (if not Bun/Deno)
139175
if (!isBun && !isDeno) {
140-
const nodeVersion = process.version
141-
const major = Number.parseInt(nodeVersion.slice(1).split('.')[0] || '0', 10)
142-
143-
if (major < 18) {
176+
if (!semverSatisfies(process.versions.node, '>= 18.0.0')) {
144177
checks.push({
178+
id: 'UNSUPPORTED_NODE',
145179
name: 'Versions',
146180
status: 'error',
147181
message: `${runtime}, Nuxt ${nuxtVersion} - Node.js 18+ required`,
182+
suggestion: 'Upgrade Node.js to v18 or later',
183+
url: 'https://nuxt.com/docs/getting-started/installation#prerequisites',
148184
})
149185
return
150186
}
@@ -181,10 +217,13 @@ function checkConfig(checks: DoctorCheck[], nuxt: Nuxt): void {
181217

182218
if (issues.length > 0) {
183219
checks.push({
220+
id: 'CONFIG_ISSUES',
184221
name: 'Config',
185222
status: 'warning',
186-
message: `${issues.length} issue${issues.length > 1 ? 's' : ''} found`,
223+
message: `${issues.length} issue${plural(issues.length)} found`,
187224
details: issues,
225+
suggestion: 'Review nuxt.config.ts and fix the issues above',
226+
url: 'https://nuxt.com/docs/getting-started/configuration',
188227
})
189228
}
190229
else {
@@ -224,17 +263,20 @@ async function checkModuleCompat(checks: DoctorCheck[], nuxt: Nuxt, cwd: string)
224263

225264
if (issues.length > 0) {
226265
checks.push({
266+
id: 'MODULE_COMPAT',
227267
name: 'Modules',
228268
status: 'warning',
229-
message: `${issues.length} incompatible module${issues.length > 1 ? 's' : ''}`,
269+
message: `${issues.length} incompatible module${plural(issues.length)}`,
230270
details: issues,
271+
suggestion: 'Update modules to versions compatible with your Nuxt version',
272+
url: 'https://nuxt.com/modules',
231273
})
232274
}
233275
else if (moduleDetails.length > 0) {
234276
checks.push({
235277
name: 'Modules',
236278
status: 'success',
237-
message: `${moduleDetails.length} module${moduleDetails.length > 1 ? 's' : ''} loaded`,
279+
message: `${moduleDetails.length} module${plural(moduleDetails.length)} loaded`,
238280
details: moduleDetails,
239281
})
240282
}
@@ -247,20 +289,41 @@ async function checkModuleCompat(checks: DoctorCheck[], nuxt: Nuxt, cwd: string)
247289
}
248290
}
249291

250-
function displayResults(checks: DoctorCheck[]): void {
292+
const statusStyles = {
293+
success: { icon: '✓', color: colors.green, detailColor: colors.dim },
294+
warning: { icon: '!', color: colors.yellow, detailColor: colors.yellow },
295+
error: { icon: '✗', color: colors.red, detailColor: colors.red },
296+
} as const
297+
298+
function displayResults(checks: DoctorCheck[], opts: { verbose?: boolean, json?: boolean }): void {
299+
if (opts.json) {
300+
// eslint-disable-next-line no-console
301+
console.log(JSON.stringify(checks))
302+
return
303+
}
304+
251305
for (const check of checks) {
252-
const icon = check.status === 'success' ? colors.green('✓') : check.status === 'warning' ? colors.yellow('!') : colors.red('✗')
306+
const style = statusStyles[check.status]
307+
const icon = style.color(style.icon)
253308
const source = check.source ? colors.gray(` (via ${check.source})`) : ''
254309
const name = colors.bold(check.name)
255-
const message = check.status === 'error' ? colors.red(check.message) : check.status === 'warning' ? colors.yellow(check.message) : check.message
310+
const message = check.status === 'success' ? check.message : style.color(check.message)
256311

257-
// Build output with details on same block
258312
let output = `[${icon}] ${name}${source} - ${message}`
259313

260-
if (check.details?.length) {
261-
const detailColor = check.status === 'error' ? colors.red : check.status === 'warning' ? colors.yellow : colors.dim
262-
for (const detail of check.details) {
263-
output += `\n ${detailColor('→')} ${detailColor(detail)}`
314+
const details = [check.details ?? []].flat()
315+
if (details.length) {
316+
for (const detail of details)
317+
output += `\n ${style.detailColor('→')} ${style.detailColor(detail)}`
318+
}
319+
320+
// Verbose: show suggestion and url
321+
if (opts.verbose) {
322+
if (check.suggestion) {
323+
output += `\n ${colors.cyan('💡')} ${colors.cyan(check.suggestion)}`
324+
}
325+
if (check.url) {
326+
output += `\n ${colors.blue('🔗')} ${colors.blue(check.url)}`
264327
}
265328
}
266329

0 commit comments

Comments
 (0)