Skip to content

Commit 771f133

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

File tree

4 files changed

+747
-0
lines changed

4 files changed

+747
-0
lines changed
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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+
// 1. Run built-in checks
116+
await runCoreChecks(checks, nuxt, cwd)
117+
118+
// 2. Let modules contribute via hook
119+
await nuxt.callHook('doctor:check', {
120+
addCheck: (c: DoctorCheck) => checks.push(c),
121+
nuxt,
122+
})
123+
124+
// 3. Display results
125+
displayResults(checks, { verbose: ctx.args.verbose, json: ctx.args.json })
126+
}
127+
finally {
128+
await nuxt.close()
129+
}
130+
131+
const hasErrors = checks.some(c => c.status === 'error')
132+
const hasWarnings = checks.some(c => c.status === 'warning')
133+
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!'))
141+
}
142+
143+
if (hasErrors)
144+
process.exit(1)
145+
},
146+
})
147+
148+
async function runCoreChecks(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise<void> {
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+
}
157+
158+
await runCheck('Versions', () => checkVersions(checks, cwd))
159+
await runCheck('Config', () => checkConfig(checks, nuxt))
160+
await runCheck('Modules', () => checkModuleCompat(checks, nuxt, cwd))
161+
}
162+
163+
async function checkVersions(checks: DoctorCheck[], cwd: string): Promise<void> {
164+
const runtime = isBun
165+
// @ts-expect-error Bun global
166+
? `Bun ${Bun?.version}`
167+
: isDeno
168+
// @ts-expect-error Deno global
169+
? `Deno ${Deno?.version.deno}`
170+
: `Node ${process.version}`
171+
172+
const nuxtVersion = await resolveNuxtVersion(cwd) ?? 'unknown'
173+
174+
// Check Node.js version (if not Bun/Deno)
175+
if (!isBun && !isDeno) {
176+
if (!semverSatisfies(process.versions.node, '>= 18.0.0')) {
177+
checks.push({
178+
id: 'UNSUPPORTED_NODE',
179+
name: 'Versions',
180+
status: 'error',
181+
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',
184+
})
185+
return
186+
}
187+
}
188+
189+
checks.push({
190+
name: 'Versions',
191+
status: 'success',
192+
message: `${runtime}, Nuxt ${nuxtVersion}`,
193+
})
194+
}
195+
196+
function checkConfig(checks: DoctorCheck[], nuxt: Nuxt): void {
197+
const issues: string[] = []
198+
199+
// Check for common misconfigurations
200+
if (nuxt.options.ssr === false && nuxt.options.nitro?.prerender?.routes?.length) {
201+
issues.push('prerender routes defined but SSR is disabled')
202+
}
203+
204+
// Check for deprecated options
205+
if ((nuxt.options as any).target) {
206+
issues.push('deprecated "target" option - use ssr + nitro.preset instead')
207+
}
208+
209+
if ((nuxt.options as any).mode) {
210+
issues.push('deprecated "mode" option - use ssr: true/false instead')
211+
}
212+
213+
// Check for missing compatibilityDate
214+
if (!nuxt.options.compatibilityDate) {
215+
issues.push('missing "compatibilityDate" - add to nuxt.config.ts for future compat')
216+
}
217+
218+
if (issues.length > 0) {
219+
checks.push({
220+
id: 'CONFIG_ISSUES',
221+
name: 'Config',
222+
status: 'warning',
223+
message: `${issues.length} issue${plural(issues.length)} found`,
224+
details: issues,
225+
suggestion: 'Review nuxt.config.ts and fix the issues above',
226+
url: 'https://nuxt.com/docs/getting-started/configuration',
227+
})
228+
}
229+
else {
230+
checks.push({
231+
name: 'Config',
232+
status: 'success',
233+
message: 'no issues',
234+
})
235+
}
236+
}
237+
238+
async function checkModuleCompat(checks: DoctorCheck[], nuxt: Nuxt, cwd: string): Promise<void> {
239+
const nuxtVersion = await resolveNuxtVersion(cwd)
240+
if (!nuxtVersion) {
241+
checks.push({
242+
name: 'Modules',
243+
status: 'warning',
244+
message: 'could not determine Nuxt version for compatibility check',
245+
})
246+
return
247+
}
248+
249+
const installedModules: { meta?: { name?: string, version?: string, compatibility?: { nuxt?: string } } }[] = (nuxt.options as any)._installedModules || []
250+
const moduleDetails: string[] = []
251+
const issues: string[] = []
252+
253+
for (const mod of installedModules) {
254+
if (!mod.meta?.name)
255+
continue
256+
257+
const name = mod.meta.name
258+
const version = mod.meta.version ? `@${mod.meta.version}` : ''
259+
const compat = mod.meta.compatibility
260+
261+
if (compat?.nuxt && !semverSatisfies(nuxtVersion, compat.nuxt, { includePrerelease: true })) {
262+
issues.push(`${name}${version} - requires nuxt ${compat.nuxt}`)
263+
}
264+
else {
265+
moduleDetails.push(`${name}${version}`)
266+
}
267+
}
268+
269+
if (issues.length > 0) {
270+
checks.push({
271+
id: 'MODULE_COMPAT',
272+
name: 'Modules',
273+
status: 'warning',
274+
message: `${issues.length} incompatible module${plural(issues.length)}`,
275+
details: issues,
276+
suggestion: 'Update modules to versions compatible with your Nuxt version',
277+
url: 'https://nuxt.com/modules',
278+
})
279+
}
280+
else if (moduleDetails.length > 0) {
281+
checks.push({
282+
name: 'Modules',
283+
status: 'success',
284+
message: `${moduleDetails.length} module${plural(moduleDetails.length)} loaded`,
285+
details: moduleDetails,
286+
})
287+
}
288+
else {
289+
checks.push({
290+
name: 'Modules',
291+
status: 'success',
292+
message: 'no modules installed',
293+
})
294+
}
295+
}
296+
297+
const statusStyles = {
298+
success: { icon: '✓', color: colors.green, detailColor: colors.dim },
299+
warning: { icon: '!', color: colors.yellow, detailColor: colors.yellow },
300+
error: { icon: '✗', color: colors.red, detailColor: colors.red },
301+
} as const
302+
303+
function displayResults(checks: DoctorCheck[], opts: { verbose?: boolean, json?: boolean }): void {
304+
if (opts.json) {
305+
// eslint-disable-next-line no-console
306+
console.log(JSON.stringify(checks))
307+
return
308+
}
309+
310+
for (const check of checks) {
311+
const style = statusStyles[check.status]
312+
const icon = style.color(style.icon)
313+
const source = check.source ? colors.gray(` (via ${check.source})`) : ''
314+
const name = colors.bold(check.name)
315+
const message = check.status === 'success' ? check.message : style.color(check.message)
316+
317+
let output = `[${icon}] ${name}${source} - ${message}`
318+
319+
const details = [check.details ?? []].flat()
320+
if (details.length) {
321+
for (const detail of details)
322+
output += `\n ${style.detailColor('→')} ${style.detailColor(detail)}`
323+
}
324+
325+
// Verbose: show suggestion and url
326+
if (opts.verbose) {
327+
if (check.suggestion) {
328+
output += `\n ${colors.cyan('💡')} ${colors.cyan(check.suggestion)}`
329+
}
330+
if (check.url) {
331+
output += `\n ${colors.blue('🔗')} ${colors.blue(check.url)}`
332+
}
333+
}
334+
335+
log.message(output)
336+
}
337+
}

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)