Skip to content

Commit ec8a097

Browse files
committed
feat(nuxi): add nuxt doctor diagnostic command
1 parent fae912b commit ec8a097

File tree

4 files changed

+769
-0
lines changed

4 files changed

+769
-0
lines changed
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
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, legacyRootDirArgs, logLevelArgs } from './_shared'
15+
16+
interface DoctorCheck {
17+
// Required
18+
name: string
19+
status: 'success' | 'warning' | 'error'
20+
message: string
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>
33+
}
34+
35+
interface DoctorCheckContext {
36+
addCheck: (check: DoctorCheck) => void
37+
nuxt: Nuxt
38+
}
39+
40+
declare module '@nuxt/schema' {
41+
interface NuxtHooks {
42+
'doctor:check': (ctx: DoctorCheckContext) => void | Promise<void>
43+
}
44+
}
45+
46+
const plural = (n: number) => n === 1 ? '' : 's'
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+
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}`)
60+
}
61+
}
62+
}
63+
64+
export default defineCommand({
65+
meta: {
66+
name: 'doctor',
67+
description: 'Run diagnostic checks on Nuxt project',
68+
},
69+
args: {
70+
...cwdArgs,
71+
...legacyRootDirArgs,
72+
...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+
},
81+
},
82+
async run(ctx) {
83+
const cwd = resolve(ctx.args.cwd || ctx.args.rootDir)
84+
85+
if (!ctx.args.json)
86+
intro(colors.cyan('Running diagnostics...'))
87+
88+
const { loadNuxt } = await loadKit(cwd)
89+
90+
let nuxt: Nuxt
91+
try {
92+
nuxt = await loadNuxt({
93+
cwd,
94+
ready: true,
95+
overrides: {
96+
logLevel: ctx.args.logLevel as 'silent' | 'info' | 'verbose' | undefined,
97+
},
98+
})
99+
}
100+
catch (err) {
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)
110+
}
111+
112+
const checks: DoctorCheck[] = []
113+
114+
try {
115+
await runCoreChecks(checks, nuxt, cwd)
116+
117+
const addCheck = (c: DoctorCheck) => {
118+
const validStatus = c?.status === 'success' || c?.status === 'warning' || c?.status === 'error'
119+
if (!c?.name || !c?.message || !validStatus) {
120+
checks.push({
121+
id: 'INVALID_DOCTOR_CHECK',
122+
name: 'Doctor',
123+
status: 'error',
124+
message: 'Invalid doctor:check payload from module',
125+
source: c?.source,
126+
data: { received: c },
127+
})
128+
return
129+
}
130+
checks.push(c)
131+
}
132+
133+
try {
134+
await nuxt.callHook('doctor:check', { addCheck, nuxt })
135+
}
136+
catch (err) {
137+
checks.push({
138+
id: 'DOCTOR_HOOK_FAILED',
139+
name: 'Doctor',
140+
status: 'error',
141+
message: `doctor:check hook failed: ${err instanceof Error ? err.message : String(err)}`,
142+
})
143+
}
144+
145+
displayResults(checks, { verbose: ctx.args.verbose, json: ctx.args.json })
146+
}
147+
finally {
148+
await nuxt.close()
149+
}
150+
151+
const hasErrors = checks.some(c => c.status === 'error')
152+
const hasWarnings = checks.some(c => c.status === 'warning')
153+
154+
if (!ctx.args.json) {
155+
if (hasErrors)
156+
outro(colors.red('Diagnostics complete with errors'))
157+
else if (hasWarnings)
158+
outro(colors.yellow('Diagnostics complete with warnings'))
159+
else
160+
outro(colors.green('All checks passed!'))
161+
}
162+
163+
if (hasErrors)
164+
process.exit(1)
165+
},
166+
})
167+
168+
async function runCoreChecks(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise<void> {
169+
const runCheck = async (name: string, fn: () => void | Promise<void>) => {
170+
try {
171+
await fn()
172+
}
173+
catch (err) {
174+
checks.push({ name, status: 'error', message: `Check failed: ${err instanceof Error ? err.message : String(err)}` })
175+
}
176+
}
177+
178+
await runCheck('Versions', () => checkVersions(checks, cwd))
179+
await runCheck('Config', () => checkConfig(checks, nuxt))
180+
await runCheck('Modules', () => checkModuleCompat(checks, nuxt, cwd))
181+
}
182+
183+
async function checkVersions(checks: DoctorCheck[], cwd: string): Promise<void> {
184+
const runtime = isBun
185+
// @ts-expect-error Bun global
186+
? `Bun ${Bun?.version}`
187+
: isDeno
188+
// @ts-expect-error Deno global
189+
? `Deno ${Deno?.version.deno}`
190+
: `Node ${process.version}`
191+
192+
const nuxtVersion = await resolveNuxtVersion(cwd) ?? 'unknown'
193+
194+
// Check Node.js version (if not Bun/Deno)
195+
if (!isBun && !isDeno) {
196+
if (!semverSatisfies(process.versions.node, '>= 18.0.0')) {
197+
checks.push({
198+
id: 'UNSUPPORTED_NODE',
199+
name: 'Versions',
200+
status: 'error',
201+
message: `${runtime}, Nuxt ${nuxtVersion} - Node.js 18+ required`,
202+
suggestion: 'Upgrade Node.js to v18 or later',
203+
url: 'https://nuxt.com/docs/getting-started/installation#prerequisites',
204+
})
205+
return
206+
}
207+
}
208+
209+
checks.push({
210+
name: 'Versions',
211+
status: 'success',
212+
message: `${runtime}, Nuxt ${nuxtVersion}`,
213+
})
214+
}
215+
216+
function checkConfig(checks: DoctorCheck[], nuxt: Nuxt): void {
217+
const issues: string[] = []
218+
219+
// Check for common misconfigurations
220+
if (nuxt.options.ssr === false && nuxt.options.nitro?.prerender?.routes?.length) {
221+
issues.push('prerender routes defined but SSR is disabled')
222+
}
223+
224+
// Check for deprecated options
225+
if ((nuxt.options as any).target) {
226+
issues.push('deprecated "target" option - use ssr + nitro.preset instead')
227+
}
228+
229+
if ((nuxt.options as any).mode) {
230+
issues.push('deprecated "mode" option - use ssr: true/false instead')
231+
}
232+
233+
// Check for missing compatibilityDate
234+
if (!nuxt.options.compatibilityDate) {
235+
issues.push('missing "compatibilityDate" - add to nuxt.config.ts for future compat')
236+
}
237+
238+
if (issues.length > 0) {
239+
checks.push({
240+
id: 'CONFIG_ISSUES',
241+
name: 'Config',
242+
status: 'warning',
243+
message: `${issues.length} issue${plural(issues.length)} found`,
244+
details: issues,
245+
suggestion: 'Review nuxt.config.ts and fix the issues above',
246+
url: 'https://nuxt.com/docs/getting-started/configuration',
247+
})
248+
}
249+
else {
250+
checks.push({
251+
name: 'Config',
252+
status: 'success',
253+
message: 'no issues',
254+
})
255+
}
256+
}
257+
258+
async function checkModuleCompat(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise<void> {
259+
const nuxtVersion = await resolveNuxtVersion(cwd)
260+
if (!nuxtVersion) {
261+
checks.push({
262+
name: 'Modules',
263+
status: 'warning',
264+
message: 'could not determine Nuxt version for compatibility check',
265+
})
266+
return
267+
}
268+
269+
const installedModules: { meta?: { name?: string, version?: string, compatibility?: { nuxt?: string } } }[] = (nuxt.options as any)._installedModules || []
270+
const moduleDetails: string[] = []
271+
const issues: string[] = []
272+
273+
for (const mod of installedModules) {
274+
if (!mod.meta?.name)
275+
continue
276+
277+
const name = mod.meta.name
278+
const version = mod.meta.version ? `@${mod.meta.version}` : ''
279+
const compat = mod.meta.compatibility
280+
281+
if (compat?.nuxt && !semverSatisfies(nuxtVersion, compat.nuxt, { includePrerelease: true })) {
282+
issues.push(`${name}${version} - requires nuxt ${compat.nuxt}`)
283+
}
284+
else {
285+
moduleDetails.push(`${name}${version}`)
286+
}
287+
}
288+
289+
if (issues.length > 0) {
290+
checks.push({
291+
id: 'MODULE_COMPAT',
292+
name: 'Modules',
293+
status: 'warning',
294+
message: `${issues.length} incompatible module${plural(issues.length)}`,
295+
details: issues,
296+
suggestion: 'Update modules to versions compatible with your Nuxt version',
297+
url: 'https://nuxt.com/modules',
298+
})
299+
}
300+
else if (moduleDetails.length > 0) {
301+
checks.push({
302+
name: 'Modules',
303+
status: 'success',
304+
message: `${moduleDetails.length} module${plural(moduleDetails.length)} loaded`,
305+
details: moduleDetails,
306+
})
307+
}
308+
else {
309+
checks.push({
310+
name: 'Modules',
311+
status: 'success',
312+
message: 'no modules installed',
313+
})
314+
}
315+
}
316+
317+
const statusStyles = {
318+
success: { icon: '✓', color: colors.green, detailColor: colors.dim },
319+
warning: { icon: '!', color: colors.yellow, detailColor: colors.yellow },
320+
error: { icon: '✗', color: colors.red, detailColor: colors.red },
321+
} as const
322+
323+
function displayResults(checks: DoctorCheck[], opts: { verbose?: boolean, json?: boolean }): void {
324+
if (opts.json) {
325+
// eslint-disable-next-line no-console
326+
console.log(JSON.stringify(checks))
327+
return
328+
}
329+
330+
for (const check of checks) {
331+
const style = statusStyles[check.status]
332+
const icon = style.color(style.icon)
333+
const source = check.source ? colors.gray(` (via ${check.source})`) : ''
334+
const name = colors.bold(check.name)
335+
const message = check.status === 'success' ? check.message : style.color(check.message)
336+
337+
let output = `[${icon}] ${name}${source} - ${message}`
338+
339+
const details = [check.details ?? []].flat()
340+
if (details.length) {
341+
for (const detail of details)
342+
output += `\n ${style.detailColor('→')} ${style.detailColor(detail)}`
343+
}
344+
345+
// Verbose: show suggestion and url
346+
if (opts.verbose) {
347+
if (check.suggestion) {
348+
output += `\n ${colors.cyan('💡')} ${colors.cyan(check.suggestion)}`
349+
}
350+
if (check.url) {
351+
output += `\n ${colors.blue('🔗')} ${colors.blue(check.url)}`
352+
}
353+
}
354+
355+
log.message(output)
356+
}
357+
}

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)