Skip to content

Commit 9022816

Browse files
committed
fix(info): make project reporting resilient
1 parent b6f2690 commit 9022816

6 files changed

Lines changed: 186 additions & 85 deletions

File tree

packages/nuxt-cli/src/commands/info.ts

Lines changed: 94 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { NuxtConfig, NuxtModule } from '@nuxt/schema'
1+
import type { NuxtModule } from '@nuxt/schema'
22
import type { PackageJson } from 'pkg-types'
33

44
import os from 'node:os'
@@ -36,66 +36,52 @@ export default defineCommand({
3636
...rootDirArgs,
3737
},
3838
async run(ctx) {
39-
// Resolve rootDir
4039
const cwd = resolveRootDir(ctx.args)
41-
42-
// Load Nuxt config
43-
const nuxtConfig = await getNuxtConfig(cwd)
44-
45-
// Find nearest package.json
46-
const projectPkg = await readPackageJSON(cwd).catch(() => ({} as PackageJson))
40+
const [nuxtConfig, projectPkg, detectedPackageManager] = await Promise.all([
41+
getNuxtConfig(cwd),
42+
readPackageJSON(cwd).catch(() => ({} as PackageJson)),
43+
detectPackageManager(cwd),
44+
])
4745
const { dependencies = {}, devDependencies = {} } = projectPkg
48-
49-
// Utils to query a dependency version
5046
const nuxtPath = tryResolveNuxt(cwd)
51-
async function getDepVersion(name: string) {
52-
for (const url of [cwd, nuxtPath]) {
53-
if (!url) {
54-
continue
55-
}
56-
const pkg = await readDependencyPackageJson(name, url)
57-
if (pkg) {
58-
return pkg.version!
59-
}
47+
const versions = new Map<string, Promise<string | undefined>>()
48+
const getDepVersion = (name: string) => {
49+
let version = versions.get(name)
50+
if (!version) {
51+
version = resolveDependencyVersion(name, [cwd, nuxtPath], cwd, projectPkg, dependencies, devDependencies)
52+
versions.set(name, version)
6053
}
61-
return resolveCatalogEntry(cwd, projectPkg, name)?.specifier
62-
?? (dependencies[name] || devDependencies[name])
54+
return version
6355
}
6456

65-
async function listModules(arr: NonNullable<NuxtConfig['modules']> = []) {
66-
const info: string[] = []
67-
for (let m of arr) {
68-
if (Array.isArray(m)) {
69-
m = m[0]
70-
}
71-
const name = normalizeConfigModule(m, cwd)
72-
if (name) {
73-
const npmName = name!.split('/').splice(0, 2).join('/') // @foo/bar/baz => @foo/bar
74-
const v = await getDepVersion(npmName)
75-
info.push(`\`${v ? `${name}@${v}` : name}\``)
76-
}
57+
const modulesPromise = Promise.all((nuxtConfig.modules || []).map(async (module) => {
58+
const name = normalizeConfigModule(module, cwd)
59+
if (!name) {
60+
return null
7761
}
78-
return info.join(', ')
79-
}
80-
81-
// Check Nuxt version
82-
const nuxtVersion = await getDepVersion('nuxt') || await getDepVersion('nuxt-nightly') || '-'
62+
const specifier = Array.isArray(module) ? module[0] : module
63+
const packageName = typeof specifier === 'string' && getPackageName(specifier)
64+
const version = packageName && await getDepVersion(packageName)
65+
return `\`${version ? `${name}@${version}` : name}\``
66+
}))
67+
const [modules, nuxtVersion = '-', nitroVersion] = await Promise.all([
68+
modulesPromise,
69+
getDepVersion('nuxt').then(version => version || getDepVersion('nuxt-nightly')),
70+
getDepVersion('nitropack').then(version => version || getDepVersion('nitro')),
71+
])
8372
const builder = nuxtConfig.builder || 'vite'
84-
85-
let packageManager = (await detectPackageManager(cwd))?.name
86-
87-
if (packageManager) {
88-
packageManager += `@${getPackageManagerVersion(packageManager)}`
89-
}
90-
73+
const packageManager = detectedPackageManager
74+
? `${detectedPackageManager.name}@${getPackageManagerVersion(detectedPackageManager.command)}`
75+
: 'unknown'
9176
const osType = os.type()
92-
const builderInfo = typeof builder === 'string'
77+
const cpus = os.cpus()
78+
const builderInfo = typeof builder === 'string' && ['vite', '@nuxt/vite-builder', 'webpack', '@nuxt/webpack-builder', 'rspack', '@nuxt/rspack-builder'].includes(builder)
9379
? getBuilder(cwd, builder)
9480
: { name: 'custom', version: '0.0.0' }
9581

9682
const infoObj = {
9783
'Operating system': osType === 'Darwin' ? `macOS ${os.release()}` : osType === 'Windows_NT' ? `Windows ${os.release()}` : `${osType} ${os.release()}`,
98-
'CPU': `${os.cpus()[0]?.model || 'unknown'} (${os.cpus().length} cores)`,
84+
'CPU': `${cpus[0]?.model || 'unknown'} (${cpus.length} cores)`,
9985
...isBun
10086
// @ts-expect-error Bun global
10187
? { 'Bun version': Bun?.version as string }
@@ -104,41 +90,22 @@ export default defineCommand({
10490
? { 'Deno version': Deno?.version.deno as string }
10591
: { 'Node.js version': process.version as string },
10692
'nuxt/cli version': nuxiVersion,
107-
'Package manager': packageManager ?? 'unknown',
93+
'Package manager': packageManager,
10894
'Nuxt version': nuxtVersion,
109-
'Nitro version': await getDepVersion('nitropack') || await getDepVersion('nitro'),
95+
'Nitro version': nitroVersion,
11096
'Builder': builderInfo.name === 'custom' ? 'custom' : `${builderInfo.name.toLowerCase()}@${builderInfo.version}`,
11197
'Config': Object.keys(nuxtConfig)
11298
.map(key => `\`${key}\``)
11399
.sort()
114100
.join(', '),
115-
'Modules': await listModules(nuxtConfig.modules),
101+
'Modules': modules.filter(module => module !== null).join(', '),
116102
}
117103

118104
logger.info(`Nuxt root directory: ${styleText('cyan', nuxtConfig.rootDir || cwd)}\n`)
119105

120106
const boxStr = formatInfoBox(infoObj)
121107

122-
let firstColumnLength = 0
123-
let secondColumnLength = 0
124-
const entries = Object.entries(infoObj).map(([label, val]) => {
125-
if (label.length > firstColumnLength) {
126-
firstColumnLength = label.length + 4
127-
}
128-
if ((val || '').length > secondColumnLength) {
129-
secondColumnLength = (val || '').length + 2
130-
}
131-
return [label, val || '-'] as const
132-
})
133-
134-
// formatted for copy-pasting into an issue
135-
let copyStr = `| ${' '.repeat(firstColumnLength)} | ${' '.repeat(secondColumnLength)} |\n| ${'-'.repeat(firstColumnLength)} | ${'-'.repeat(secondColumnLength)} |\n`
136-
for (const [label, value] of entries) {
137-
if (!isMinimal) {
138-
copyStr += `| ${`**${label}**`.padEnd(firstColumnLength)} | ${(value.includes('`') ? value : `\`${value}\``).padEnd(secondColumnLength)} |\n`
139-
}
140-
}
141-
108+
const copyStr = formatMarkdownTable(infoObj)
142109
const copied = !isMinimal && await writeText(copyStr).then(() => true).catch(() => false)
143110

144111
if (copied) {
@@ -169,20 +136,69 @@ export default defineCommand({
169136
},
170137
})
171138

172-
function normalizeConfigModule(
173-
module: NuxtModule<any, any> | string | false | null | undefined,
139+
async function resolveDependencyVersion(
140+
name: string,
141+
roots: Array<string | null>,
142+
cwd: string,
143+
projectPkg: PackageJson,
144+
dependencies: Record<string, string>,
145+
devDependencies: Record<string, string>,
146+
): Promise<string | undefined> {
147+
for (const root of roots) {
148+
if (!root) {
149+
continue
150+
}
151+
const pkg = await readDependencyPackageJson(name, root)
152+
if (pkg?.version) {
153+
return pkg.version
154+
}
155+
}
156+
return resolveCatalogEntry(cwd, projectPkg, name)?.specifier
157+
?? dependencies[name]
158+
?? devDependencies[name]
159+
}
160+
161+
export function formatMarkdownTable(info: Record<string, string | undefined>): string {
162+
const entries = Object.entries(info).map(([label, value]) => [label, value || '-'] as const)
163+
const labelWidth = Math.max(...entries.map(([label]) => label.length + 4))
164+
const valueWidth = Math.max(...entries.map(([, value]) => value.length + (value.includes('`') ? 0 : 2)))
165+
const rows = entries.map(([label, value]) => {
166+
const formattedValue = value.includes('`') ? value : `\`${value}\``
167+
return `| ${`**${label}**`.padEnd(labelWidth)} | ${formattedValue.padEnd(valueWidth)} |`
168+
})
169+
return [
170+
`| ${' '.repeat(labelWidth)} | ${' '.repeat(valueWidth)} |`,
171+
`| ${'-'.repeat(labelWidth)} | ${'-'.repeat(valueWidth)} |`,
172+
...rows,
173+
'',
174+
].join('\n')
175+
}
176+
177+
export function getPackageName(name: string): string | undefined {
178+
if (name.startsWith('.') || name.startsWith('/') || /^[a-z]:[\\/]/i.test(name) || name.endsWith('()')) {
179+
return undefined
180+
}
181+
const parts = name.split('/')
182+
return name.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
183+
}
184+
185+
export function normalizeConfigModule(
186+
module: NuxtModule<any, any> | string | false | null | undefined | readonly [(NuxtModule<any, any> | string | undefined)?, unknown?],
174187
rootDir: string,
175188
): string | null {
176189
if (!module) {
177190
return null
178191
}
179192
if (typeof module === 'string') {
180-
return module
181-
.split(rootDir)
182-
.pop()! // Strip rootDir
183-
.split('node_modules')
184-
.pop()! // Strip node_modules
185-
.replace(LEADING_SLASH_RE, '')
193+
const normalized = module.replaceAll('\\', '/')
194+
const normalizedRoot = rootDir.replaceAll('\\', '/').replace(/\/$/, '')
195+
const nodeModulesIndex = normalized.lastIndexOf('/node_modules/')
196+
if (nodeModulesIndex !== -1) {
197+
return normalized.slice(nodeModulesIndex + '/node_modules/'.length)
198+
}
199+
return normalized.startsWith(`${normalizedRoot}/`)
200+
? normalized.slice(normalizedRoot.length + 1)
201+
: normalized.replace(LEADING_SLASH_RE, '')
186202
}
187203
if (typeof module === 'function') {
188204
return `${module.name}()`

packages/nuxt-cli/src/utils/banner.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ export function getBuilder(cwd: string, builder: Exclude<NuxtOptions['builder']
1717
case '@nuxt/vite-builder':
1818
default: {
1919
const pkgJSON = getPkgJSON(cwd, 'vite', { via: ['nuxt', '@nuxt/vite-builder'] })
20-
const isRolldown = pkgJSON.name.includes('rolldown')
21-
const isVitePlus = pkgJSON.name === '@voidzero-dev/vite-plus-core'
20+
const isRolldown = pkgJSON?.name.includes('rolldown')
21+
const isVitePlus = pkgJSON?.name === '@voidzero-dev/vite-plus-core'
2222
return {
2323
name: isRolldown ? 'Rolldown-Vite' : 'Vite',
24-
version: (isVitePlus ? pkgJSON.bundledVersions?.vite : pkgJSON.version) || 'unknown',
25-
provider: isVitePlus ? { name: 'Vite+', version: pkgJSON.version || 'unknown' } : undefined,
24+
version: (isVitePlus ? pkgJSON?.bundledVersions?.vite : pkgJSON?.version) || 'unknown',
25+
provider: isVitePlus ? { name: 'Vite+', version: pkgJSON?.version || 'unknown' } : undefined,
2626
}
2727
}
2828
}
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
import { execSync } from 'node:child_process'
1+
import { execFileSync } from 'node:child_process'
2+
import process from 'node:process'
23

3-
export function getPackageManagerVersion(name: string) {
4-
return execSync(`${name} --version`).toString('utf8').trim()
4+
export function getPackageManagerVersion(command: string) {
5+
// Package managers are `.cmd` shims on Windows, which cannot be spawned without a shell.
6+
const isWindows = process.platform === 'win32'
7+
try {
8+
return execFileSync(isWindows ? `"${command}"` : command, ['--version'], { shell: isWindows, stdio: ['ignore', 'pipe', 'ignore'] }).toString('utf8').trim()
9+
}
10+
catch {
11+
return 'unknown'
12+
}
513
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { formatMarkdownTable, getPackageName, normalizeConfigModule } from '../../../src/commands/info'
4+
5+
describe('info', () => {
6+
describe('formatMarkdownTable', () => {
7+
it('includes rows in minimal environments', () => {
8+
expect(formatMarkdownTable({
9+
'Nuxt version': '4.0.0',
10+
'Modules': '`@nuxt/image@1.0.0`',
11+
'Config': '',
12+
})).toMatchInlineSnapshot(`
13+
"| | |
14+
| ---------------- | ------------------- |
15+
| **Nuxt version** | \`4.0.0\` |
16+
| **Modules** | \`@nuxt/image@1.0.0\` |
17+
| **Config** | \`-\` |
18+
"
19+
`)
20+
})
21+
})
22+
23+
describe('getPackageName', () => {
24+
it.each([
25+
['@nuxt/image', '@nuxt/image'],
26+
['@nuxt/image/module', '@nuxt/image'],
27+
['example/module', 'example'],
28+
['example', 'example'],
29+
['./modules/example', undefined],
30+
['/project/modules/example', undefined],
31+
['C:\\project\\modules\\example', undefined],
32+
['exampleModule()', undefined],
33+
])('gets the package name for %s', (module, expected) => {
34+
expect(getPackageName(module)).toBe(expected)
35+
})
36+
})
37+
38+
describe('normalizeConfigModule', () => {
39+
it.each([
40+
['/project/modules/example', '/project', 'modules/example'],
41+
['/project/node_modules/@nuxt/image/dist/module.mjs', '/project', '@nuxt/image/dist/module.mjs'],
42+
['/project/node_modules/foo/node_modules/bar/index.mjs', '/project', 'bar/index.mjs'],
43+
['C:\\project\\modules\\example', 'C:\\project', 'modules/example'],
44+
['@nuxt/image', '/project', '@nuxt/image'],
45+
])('normalizes %s', (module, rootDir, expected) => {
46+
expect(normalizeConfigModule(module, rootDir)).toBe(expected)
47+
expect(normalizeConfigModule([module, {}], rootDir)).toBe(expected)
48+
})
49+
50+
it('formats function modules', () => {
51+
function exampleModule() {}
52+
expect(normalizeConfigModule(exampleModule, '/project')).toBe('exampleModule()')
53+
})
54+
})
55+
})

packages/nuxt-cli/test/unit/utils/banner.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const VERSIONS: Record<string, string> = {
1515
vi.mock('../../../src/utils/pkg', () => ({
1616
getPkgJSON: vi.fn((_cwd: string, pkg: string, options?: { via?: string[] }) => {
1717
if (pkg === 'vite' && options?.via?.includes('@nuxt/vite-builder')) {
18+
if (_cwd === '/missing') {
19+
return null
20+
}
1821
if (_cwd === '/vite-plus') {
1922
return { name: '@voidzero-dev/vite-plus-core', version: '0.2.6', bundledVersions: { vite: '8.1.5' } }
2023
}
@@ -40,6 +43,10 @@ describe('getBuilder', () => {
4043
expect(getBuilder('/any', 'vite')).toEqual({ name: 'Vite', version: '7.3.1' })
4144
})
4245

46+
it('reports an unknown vite version when vite is unavailable', () => {
47+
expect(getBuilder('/missing', 'vite')).toEqual({ name: 'Vite', version: 'unknown', provider: undefined })
48+
})
49+
4350
it('resolves the bundled vite version from Vite+', () => {
4451
expect(getBuilder('/vite-plus', 'vite')).toEqual({
4552
name: 'Vite',
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import process from 'node:process'
2+
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { getPackageManagerVersion } from '../../../src/utils/packageManagers'
6+
7+
describe('getPackageManagerVersion', () => {
8+
it('returns the command version', () => {
9+
expect(getPackageManagerVersion(process.execPath)).toBe(process.version)
10+
})
11+
12+
it('does not fail when the package manager is unavailable', () => {
13+
expect(getPackageManagerVersion('nuxt-cli-missing-package-manager')).toBe('unknown')
14+
})
15+
})

0 commit comments

Comments
 (0)