Skip to content

Commit 3868c71

Browse files
committed
feat(nuxi): add nuxt doctor diagnostic command
Hook-based diagnostic command. Core checks built-in, modules contribute via `doctor:check` hook. - Validates config against common misconfigurations - Checks Node/Nuxt version compatibility - Detects module compatibility issues - Supports --verbose and --json flags - Graceful hook error handling
1 parent fae912b commit 3868c71

5 files changed

Lines changed: 746 additions & 0 deletions

File tree

packages/nuxi/src/commands/_utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export const nuxiCommands = [
1010
'_dev',
1111
'dev',
1212
'devtools',
13+
'doctor',
1314
'generate',
1415
'info',
1516
'init',
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import type { Nuxt } from '@nuxt/schema'
2+
3+
import process from 'node:process'
4+
5+
import { intro, log, outro } from '@clack/prompts'
6+
import { defineCommand } from 'citty'
7+
import { colors } from 'consola/utils'
8+
import { resolve } from 'pathe'
9+
import { readPackageJSON } from 'pkg-types'
10+
import { satisfies as semverSatisfies } from 'semver'
11+
import { isBun, isDeno } from 'std-env'
12+
13+
import { loadKit, tryResolveNuxt } from '../utils/kit'
14+
import { cwdArgs, dotEnvArgs, extendsArgs, legacyRootDirArgs, logLevelArgs } from './_shared'
15+
16+
// Types for the hook-based architecture (will move to @nuxt/schema)
17+
interface DoctorCheck {
18+
// Required
19+
name: string
20+
status: 'success' | 'warning' | 'error'
21+
message: string
22+
23+
// Optional - identity/origin
24+
id?: string // programmatic code: "MISSING_PEER_DEP"
25+
source?: string // module name: "@nuxt/ui"
26+
27+
// Optional - verbose fields
28+
details?: string | string[]
29+
suggestion?: string
30+
url?: string
31+
32+
// Optional - programmatic
33+
data?: Record<string, unknown>
34+
}
35+
36+
interface DoctorCheckContext {
37+
addCheck: (check: DoctorCheck) => void
38+
nuxt: Nuxt
39+
}
40+
41+
// Augment NuxtHooks for the doctor:check hook (will be moved to @nuxt/schema)
42+
declare module '@nuxt/schema' {
43+
interface NuxtHooks {
44+
'doctor:check': (ctx: DoctorCheckContext) => void | Promise<void>
45+
}
46+
}
47+
48+
async function resolveNuxtVersion(cwd: string): Promise<string | undefined> {
49+
const nuxtPath = tryResolveNuxt(cwd)
50+
for (const pkg of ['nuxt', 'nuxt-nightly', 'nuxt-edge', 'nuxt3']) {
51+
const pkgJson = await readPackageJSON(pkg, { url: nuxtPath || cwd }).catch(() => null)
52+
if (pkgJson?.version) {
53+
return pkgJson.version
54+
}
55+
}
56+
}
57+
58+
export default defineCommand({
59+
meta: {
60+
name: 'doctor',
61+
description: 'Run diagnostic checks on Nuxt project',
62+
},
63+
args: {
64+
...cwdArgs,
65+
...legacyRootDirArgs,
66+
...logLevelArgs,
67+
...dotEnvArgs,
68+
...extendsArgs,
69+
verbose: {
70+
type: 'boolean',
71+
description: 'Show details, suggestions, and URLs',
72+
},
73+
json: {
74+
type: 'boolean',
75+
description: 'Output results as JSON',
76+
},
77+
},
78+
async run(ctx) {
79+
const cwd = resolve(ctx.args.cwd || ctx.args.rootDir)
80+
const isJson = ctx.args.json
81+
82+
if (!isJson) {
83+
intro(colors.cyan('Running diagnostics...'))
84+
}
85+
86+
const { loadNuxt } = await loadKit(cwd)
87+
88+
let nuxt: Nuxt
89+
try {
90+
nuxt = await loadNuxt({
91+
cwd,
92+
ready: true,
93+
overrides: {
94+
logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose' | undefined,
95+
},
96+
})
97+
}
98+
catch (err) {
99+
if (isJson) {
100+
// eslint-disable-next-line no-console
101+
console.log(JSON.stringify([{ name: 'Setup', status: 'error', message: `Failed to load Nuxt: ${err instanceof Error ? err.message : String(err)}` }]))
102+
}
103+
else {
104+
log.error(colors.red(`Failed to load Nuxt: ${err instanceof Error ? err.message : String(err)}`))
105+
outro(colors.red('Diagnostics failed'))
106+
}
107+
process.exit(1)
108+
return // unreachable but needed for type narrowing in tests
109+
}
110+
111+
const checks: DoctorCheck[] = []
112+
113+
try {
114+
// 1. Run built-in checks
115+
await runCoreChecks(checks, nuxt, cwd)
116+
117+
// 2. Let modules contribute via hook (catch errors to preserve partial results)
118+
try {
119+
await nuxt.callHook('doctor:check', {
120+
addCheck: (c: DoctorCheck) => checks.push(c),
121+
nuxt,
122+
})
123+
}
124+
catch (err) {
125+
checks.push({
126+
id: 'HOOK_ERROR',
127+
name: 'Module Checks',
128+
status: 'error',
129+
message: `Hook failed: ${err instanceof Error ? err.message : String(err)}`,
130+
})
131+
}
132+
133+
// 3. Display results
134+
displayResults(checks, { verbose: ctx.args.verbose, json: isJson })
135+
}
136+
finally {
137+
await nuxt.close()
138+
}
139+
140+
const hasErrors = checks.some(c => c.status === 'error')
141+
const hasWarnings = checks.some(c => c.status === 'warning')
142+
143+
if (!isJson) {
144+
if (hasErrors) {
145+
outro(colors.red('Diagnostics complete with errors'))
146+
}
147+
else if (hasWarnings) {
148+
outro(colors.yellow('Diagnostics complete with warnings'))
149+
}
150+
else {
151+
outro(colors.green('All checks passed!'))
152+
}
153+
}
154+
155+
if (hasErrors) {
156+
process.exit(1)
157+
}
158+
},
159+
})
160+
161+
async function runCoreChecks(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise<void> {
162+
// Version check
163+
await checkVersions(checks, cwd)
164+
165+
// Config validation
166+
checkConfig(checks, nuxt)
167+
168+
// Module compatibility
169+
await checkModuleCompat(checks, nuxt, cwd)
170+
}
171+
172+
async function checkVersions(checks: DoctorCheck[], cwd: string): Promise<void> {
173+
const runtime = isBun
174+
// @ts-expect-error Bun global
175+
? `Bun ${Bun?.version}`
176+
: isDeno
177+
// @ts-expect-error Deno global
178+
? `Deno ${Deno?.version.deno}`
179+
: `Node ${process.version}`
180+
181+
const nuxtVersion = await resolveNuxtVersion(cwd) ?? 'unknown'
182+
183+
// Check Node.js version (if not Bun/Deno)
184+
if (!isBun && !isDeno) {
185+
const nodeVersion = process.version
186+
const major = Number.parseInt(nodeVersion.slice(1).split('.')[0] || '0', 10)
187+
188+
if (major < 18) {
189+
checks.push({
190+
id: 'UNSUPPORTED_NODE',
191+
name: 'Versions',
192+
status: 'error',
193+
message: `${runtime}, Nuxt ${nuxtVersion} - Node.js 18+ required`,
194+
suggestion: 'Upgrade Node.js to v18 or later',
195+
url: 'https://nuxt.com/docs/getting-started/installation#prerequisites',
196+
})
197+
return
198+
}
199+
}
200+
201+
checks.push({
202+
name: 'Versions',
203+
status: 'success',
204+
message: `${runtime}, Nuxt ${nuxtVersion}`,
205+
})
206+
}
207+
208+
function checkConfig(checks: DoctorCheck[], nuxt: Nuxt): void {
209+
const issues: string[] = []
210+
211+
// Check for common misconfigurations
212+
if (nuxt.options.ssr === false && nuxt.options.nitro?.prerender?.routes?.length) {
213+
issues.push('prerender routes defined but SSR is disabled')
214+
}
215+
216+
// Check for deprecated options
217+
if ((nuxt.options as any).target) {
218+
issues.push('deprecated "target" option - use ssr + nitro.preset instead')
219+
}
220+
221+
if ((nuxt.options as any).mode) {
222+
issues.push('deprecated "mode" option - use ssr: true/false instead')
223+
}
224+
225+
// Check for missing compatibilityDate
226+
if (!nuxt.options.compatibilityDate) {
227+
issues.push('missing "compatibilityDate" - add to nuxt.config.ts for future compat')
228+
}
229+
230+
if (issues.length > 0) {
231+
checks.push({
232+
id: 'CONFIG_ISSUES',
233+
name: 'Config',
234+
status: 'warning',
235+
message: `${issues.length} issue${issues.length > 1 ? 's' : ''} found`,
236+
details: issues,
237+
suggestion: 'Review nuxt.config.ts and fix the issues above',
238+
url: 'https://nuxt.com/docs/getting-started/configuration',
239+
})
240+
}
241+
else {
242+
checks.push({
243+
name: 'Config',
244+
status: 'success',
245+
message: 'no issues',
246+
})
247+
}
248+
}
249+
250+
async function checkModuleCompat(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise<void> {
251+
const nuxtVersion = await resolveNuxtVersion(cwd)
252+
if (!nuxtVersion) {
253+
return
254+
}
255+
256+
const installedModules: { meta?: { name?: string, version?: string, compatibility?: { nuxt?: string } } }[] = (nuxt.options as any)._installedModules || []
257+
const moduleDetails: string[] = []
258+
const issues: string[] = []
259+
260+
for (const mod of installedModules) {
261+
if (!mod.meta?.name)
262+
continue
263+
264+
const name = mod.meta.name
265+
const version = mod.meta.version ? `@${mod.meta.version}` : ''
266+
const compat = mod.meta.compatibility
267+
268+
if (compat?.nuxt && !semverSatisfies(nuxtVersion, compat.nuxt, { includePrerelease: true })) {
269+
issues.push(`${name}${version} - requires nuxt ${compat.nuxt}`)
270+
}
271+
else {
272+
moduleDetails.push(`${name}${version}`)
273+
}
274+
}
275+
276+
if (issues.length > 0) {
277+
checks.push({
278+
id: 'MODULE_COMPAT',
279+
name: 'Modules',
280+
status: 'warning',
281+
message: `${issues.length} incompatible module${issues.length > 1 ? 's' : ''}`,
282+
details: issues,
283+
suggestion: 'Update modules to versions compatible with your Nuxt version',
284+
url: 'https://nuxt.com/modules',
285+
})
286+
}
287+
else if (moduleDetails.length > 0) {
288+
checks.push({
289+
name: 'Modules',
290+
status: 'success',
291+
message: `${moduleDetails.length} module${moduleDetails.length > 1 ? 's' : ''} loaded`,
292+
details: moduleDetails,
293+
})
294+
}
295+
else {
296+
checks.push({
297+
name: 'Modules',
298+
status: 'success',
299+
message: 'no modules installed',
300+
})
301+
}
302+
}
303+
304+
function displayResults(checks: DoctorCheck[], opts: { verbose?: boolean, json?: boolean }): void {
305+
if (opts.json) {
306+
// eslint-disable-next-line no-console
307+
console.log(JSON.stringify(checks))
308+
return
309+
}
310+
311+
for (const check of checks) {
312+
const icon = check.status === 'success' ? colors.green('✓') : check.status === 'warning' ? colors.yellow('!') : colors.red('✗')
313+
const source = check.source ? colors.gray(` (via ${check.source})`) : ''
314+
const name = colors.bold(check.name)
315+
const message = check.status === 'error' ? colors.red(check.message) : check.status === 'warning' ? colors.yellow(check.message) : check.message
316+
317+
// Build output with details on same block
318+
let output = `[${icon}] ${name}${source} - ${message}`
319+
320+
// Show details array
321+
const details = check.details ? (Array.isArray(check.details) ? check.details : [check.details]) : []
322+
if (details.length) {
323+
const detailColor = check.status === 'error' ? colors.red : check.status === 'warning' ? colors.yellow : colors.dim
324+
for (const detail of details) {
325+
output += `\n ${detailColor('→')} ${detailColor(detail)}`
326+
}
327+
}
328+
329+
// Verbose: show suggestion and url
330+
if (opts.verbose) {
331+
if (check.suggestion) {
332+
output += `\n ${colors.cyan('💡')} ${colors.cyan(check.suggestion)}`
333+
}
334+
if (check.url) {
335+
output += `\n ${colors.blue('🔗')} ${colors.blue(check.url)}`
336+
}
337+
}
338+
339+
log.message(output)
340+
}
341+
}

packages/nuxi/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const commands = {
88
'analyze': () => import('./analyze').then(_rDefault),
99
'build': () => import('./build').then(_rDefault),
1010
'cleanup': () => import('./cleanup').then(_rDefault),
11+
'doctor': () => import('./doctor').then(_rDefault),
1112
'_dev': () => import('./dev-child').then(_rDefault),
1213
'dev': () => import('./dev').then(_rDefault),
1314
'devtools': () => import('./devtools').then(_rDefault),

0 commit comments

Comments
 (0)