Skip to content

Commit a08a775

Browse files
committed
fix(module): harden module management flows
1 parent 33de9c8 commit a08a775

9 files changed

Lines changed: 174 additions & 94 deletions

File tree

packages/nuxt-cli/src/commands/module/_autocomplete.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,6 @@ interface AutocompleteResult {
1919
cancelled: boolean
2020
}
2121

22-
/**
23-
* Interactive fuzzy search for selecting Nuxt modules
24-
* Returns object with selected module npm package names and cancellation status
25-
*/
2622
export async function selectModulesAutocomplete(options: AutocompleteOptions): Promise<AutocompleteResult> {
2723
const { modules, message = 'Search and select modules:' } = options
2824

@@ -31,7 +27,6 @@ export async function selectModulesAutocomplete(options: AutocompleteOptions): P
3127
return { selected: [], cancelled: false }
3228
}
3329

34-
// Sort: official modules first, then alphabetically
3530
const sortedModules = modules.toSorted((a, b) => {
3631
if (a.type === 'official' && b.type !== 'official')
3732
return -1
@@ -40,26 +35,28 @@ export async function selectModulesAutocomplete(options: AutocompleteOptions): P
4035
return a.npm.localeCompare(b.npm)
4136
})
4237

43-
// Setup fzf for fast fuzzy search
4438
const fzf = new Fzf(sortedModules, {
4539
selector: m => `${m.npm} ${m.name} ${m.category}`,
4640
casing: 'case-insensitive',
4741
tiebreakers: [byLengthAsc],
4842
})
4943

50-
// Build options for clack multiselect
5144
const clackOptions: Option<string>[] = sortedModules.map(m => ({
5245
value: m.npm,
5346
label: m.npm,
5447
hint: m.description.replace(TRAILING_DOT_RE, ''),
5548
}))
5649

57-
// Custom filter function using fzf for fuzzy matching
50+
const matches = new Map<string, Set<string>>()
5851
const filter = (search: string, option: Option<string>): boolean => {
5952
if (!search)
6053
return true
61-
const results = fzf.find(search)
62-
return results.some(r => r.item.npm === option.value)
54+
let results = matches.get(search)
55+
if (!results) {
56+
results = new Set(fzf.find(search).map(r => r.item.npm))
57+
matches.set(search, results)
58+
}
59+
return results.has(option.value)
6360
}
6461

6562
const result = await autocompleteMultiselect({
@@ -69,9 +66,7 @@ export async function selectModulesAutocomplete(options: AutocompleteOptions): P
6966
required: false,
7067
})
7168

72-
if (isCancel(result)) {
73-
return { selected: [], cancelled: true }
74-
}
75-
76-
return { selected: result, cancelled: false }
69+
return isCancel(result)
70+
? { selected: [], cancelled: true }
71+
: { selected: result, cancelled: false }
7772
}

packages/nuxt-cli/src/commands/module/_utils.ts

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,6 @@ import { logger } from '../../utils/logger'
1313
import { relativeToProcess } from '../../utils/paths'
1414
import { cwdArgs, logLevelArgs } from '../_shared'
1515

16-
export const categories = [
17-
'Analytics',
18-
'CMS',
19-
'CSS',
20-
'Database',
21-
'Date',
22-
'Deployment',
23-
'Devtools',
24-
'Extensions',
25-
'Ecommerce',
26-
'Fonts',
27-
'Images',
28-
'Libraries',
29-
'Monitoring',
30-
'Payment',
31-
'Performance',
32-
'Request',
33-
'SEO',
34-
'Security',
35-
'UI',
36-
]
37-
3816
interface NuxtApiModulesResponse {
3917
version: string
4018
generatedAt: string
@@ -90,7 +68,7 @@ export interface NuxtModule {
9068
github: string
9169
website: string
9270
learn_more: string
93-
category: (typeof categories)[number]
71+
category: string
9472
type: ModuleType
9573
maintainers: MaintainerInfo[]
9674
contributors?: GitHubContributor[]

packages/nuxt-cli/src/commands/module/add.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { detectPackageManager, packageManagers } from 'nypm'
1313
import { resolve } from 'pathe'
1414
import { readPackageJSON } from 'pkg-types'
1515
import { joinURL } from 'ufo'
16-
import { satisfies } from 'verkit'
16+
import { findMaxSatisfying, satisfies } from 'verkit'
1717

1818
import { runCommandDef as runCommand } from '../../run-command'
1919
import { addNuxtConfigEntries, createNuxtConfig, readNuxtConfig } from '../../utils/config'
@@ -88,7 +88,7 @@ export function defineAddCommand({ layers = false }: { layers?: boolean } = {})
8888
process.exit(1)
8989
}
9090

91-
// If no modules specified, show interactive search
91+
let modulesDB: NuxtModule[]
9292
if (modules.length === 0) {
9393
const modulesSpinner = spinner()
9494
modulesSpinner.start('Fetching available modules')
@@ -102,28 +102,36 @@ export function defineAddCommand({ layers = false }: { layers?: boolean } = {})
102102
getNuxtVersion(cwd),
103103
])
104104

105-
const compatibleModules = allModules.filter(m =>
105+
modulesDB = allModules
106+
const compatibleModules = modulesDB.filter(m =>
106107
!m.compatibility.nuxt || checkNuxtCompatibility(m, nuxtVersion),
107108
)
108109

109110
modulesSpinner.stop('Modules loaded')
110111

111-
const result = await selectModulesAutocomplete({
112+
const selection = await selectModulesAutocomplete({
112113
modules: compatibleModules,
113114
message: 'Search modules to add (Esc to finish):',
114115
})
116+
modules = selection.selected
115117

116-
if (result.selected.length === 0) {
118+
if (modules.length === 0) {
117119
cancel('No modules selected.')
118120
process.exit(0)
119121
}
120-
121-
modules = result.selected
122+
}
123+
else {
124+
modulesDB = await fetchModules().catch((err) => {
125+
logNetworkError(err, { url: MODULES_API_URL, level: 'warn', prefix: 'Cannot search in the Nuxt Modules database.' })
126+
return []
127+
})
122128
}
123129

130+
let nuxtVersionPromise: Promise<string> | undefined
131+
const getProjectNuxtVersion = () => nuxtVersionPromise ||= getNuxtVersion(cwd)
124132
const resolvedModules: ResolvedModule[] = []
125-
for (const moduleName of modules) {
126-
const resolvedModule = await resolveModule(moduleName, cwd)
133+
for (const moduleName of new Set(modules)) {
134+
const resolvedModule = await resolveModule(moduleName, cwd, modulesDB, getProjectNuxtVersion)
127135
if (resolvedModule) {
128136
resolvedModules.push(resolvedModule)
129137
}
@@ -142,7 +150,6 @@ export function defineAddCommand({ layers = false }: { layers?: boolean } = {})
142150
process.exit(1)
143151
}
144152

145-
// Run prepare command if install is not skipped
146153
if (!ctx.args.skipInstall) {
147154
await runCommand(prepareCommand, forwardCommandArgs(ctx.args))
148155
}
@@ -154,7 +161,6 @@ export default defineAddCommand()
154161

155162
// -- Internal Utils --
156163
async function addModules(modules: ResolvedModule[], { skipInstall = false, skipConfig = false, cwd, dev = false, packageManager: packageManagerName, logLevel }: { skipInstall?: boolean, skipConfig?: boolean, cwd: string, dev?: boolean, packageManager?: string, logLevel?: string }, projectPkg: PackageJson): Promise<boolean> {
157-
// Add dependencies
158164
if (!skipInstall) {
159165
const installedModules: ResolvedModule[] = []
160166
const notInstalledModules: ResolvedModule[] = []
@@ -230,7 +236,6 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip
230236
}
231237
}
232238

233-
// Update nuxt.config.ts
234239
if (!skipConfig) {
235240
try {
236241
let config = await readNuxtConfig(cwd)
@@ -258,6 +263,7 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip
258263
catch (error) {
259264
logger.error(`Failed to update ${styleText('cyan', 'nuxt.config')}: ${(error as Error).message}`)
260265
logger.error(`Please manually add ${styleText('cyan', modules.map(module => module.specifier).join(', '))} to ${styleText('cyan', 'nuxt.config.ts')}`)
266+
return false
261267
}
262268
}
263269

@@ -342,7 +348,7 @@ export default defineNuxtConfig({
342348
})`
343349
}
344350

345-
async function resolveModule(moduleName: string, cwd: string): Promise<ModuleResolution> {
351+
async function resolveModule(moduleName: string, cwd: string, modulesDB: NuxtModule[], getProjectNuxtVersion: () => Promise<string>): Promise<ModuleResolution> {
346352
const spec = parseModuleSpec(moduleName)
347353

348354
if (!spec) {
@@ -353,11 +359,6 @@ async function resolveModule(moduleName: string, cwd: string): Promise<ModuleRes
353359
let { pkgName, pkgVersion } = spec
354360
let subpath = spec.subpath
355361

356-
const modulesDB = await fetchModules().catch((err) => {
357-
logNetworkError(err, { url: MODULES_API_URL, level: 'warn', prefix: 'Cannot search in the Nuxt Modules database.' })
358-
return []
359-
})
360-
361362
const bareName = subpath ? `${pkgName}/${subpath}` : pkgName
362363
const matchedModule = modulesDB.find(
363364
module =>
@@ -377,8 +378,7 @@ async function resolveModule(moduleName: string, cwd: string): Promise<ModuleRes
377378
}
378379

379380
if (matchedModule && matchedModule.compatibility.nuxt) {
380-
// Get local Nuxt version
381-
const nuxtVersion = await getNuxtVersion(cwd)
381+
const nuxtVersion = await getProjectNuxtVersion()
382382

383383
// Check for Module Compatibility
384384
if (!checkNuxtCompatibility(matchedModule, nuxtVersion)) {
@@ -449,7 +449,7 @@ async function resolveModule(moduleName: string, cwd: string): Promise<ModuleRes
449449
version = pkgDetails['dist-tags'][version]
450450
}
451451
else {
452-
version = Object.keys(pkgDetails.versions)?.findLast(v => satisfies(v, version)) || version
452+
version = findMaxSatisfying(Object.keys(pkgDetails.versions || {}), version) || version
453453
}
454454

455455
const pkg = pkgDetails.versions[version!] || {}

packages/nuxt-cli/src/commands/module/remove.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,6 @@ export default defineCommand({
6464
process.exit(1)
6565
}
6666

67-
// With no inputs, the multiselect picker runs inside `removeModules` against the
68-
// configured modules. Otherwise resolve aliases/names to canonical npm package names.
6967
const installedNames = getProjectDependencies(projectPkg)
7068

7169
const needsDB = modules.some(m => !installedNames.has(m) && !installedNames.has(basePackageName(m)))
@@ -84,19 +82,18 @@ export default defineCommand({
8482

8583
const proceed = await removeModules(resolvedModules, { ...ctx.args, cwd }, projectPkg)
8684

87-
if (!proceed) {
88-
process.exit(0)
85+
if (proceed !== true) {
86+
process.exit(proceed === false ? 1 : 0)
8987
}
9088

91-
// Run prepare command if uninstall is not skipped
9289
if (!ctx.args.skipInstall) {
9390
await runCommand(prepareCommand, forwardCommandArgs(ctx.args))
9491
}
9592
},
9693
})
9794

9895
// -- Internal Utils --
99-
async function removeModules(modules: string[], { skipInstall = false, skipConfig = false, cwd }: { skipInstall?: boolean, skipConfig?: boolean, cwd: string }, projectPkg: PackageJson): Promise<boolean> {
96+
async function removeModules(modules: string[], { skipInstall = false, skipConfig = false, cwd }: { skipInstall?: boolean, skipConfig?: boolean, cwd: string }, projectPkg: PackageJson): Promise<boolean | undefined> {
10097
const removedFromConfig: string[] = []
10198
const dependencies = getProjectDependencies(projectPkg)
10299

@@ -118,7 +115,7 @@ async function removeModules(modules: string[], { skipInstall = false, skipConfi
118115

119116
if (isCancel(picked)) {
120117
cancel('No modules selected.')
121-
return false
118+
return
122119
}
123120

124121
toRemove = new Set(picked as string[])
@@ -140,17 +137,21 @@ async function removeModules(modules: string[], { skipInstall = false, skipConfi
140137
removedFromConfig.push(...names)
141138
}
142139

143-
await removeNuxtConfigEntries(config, doomed).catch((error) => {
140+
try {
141+
await removeNuxtConfigEntries(config, doomed)
142+
}
143+
catch (error) {
144144
logger.error(`Failed to update ${styleText('cyan', 'nuxt.config')}: ${(error as Error).message}`)
145-
logger.error(`Please manually remove ${styleText('cyan', modules.join(', ') || 'the relevant modules')} from ${styleText('cyan', 'nuxt.config.ts')}`)
146-
})
145+
logger.error(`Please manually remove ${styleText('cyan', [...toRemove].join(', ') || 'the relevant modules')} from ${styleText('cyan', 'nuxt.config.ts')}`)
146+
return false
147+
}
147148
}
148149

149150
if (modules.length === 0 && removedFromConfig.length === 0) {
150151
cancel(config
151152
? `No modules configured in ${styleText('cyan', 'nuxt.config')}.`
152153
: `No ${styleText('cyan', 'nuxt.config')} found in ${styleText('cyan', relativeToProcess(cwd))}.`)
153-
return false
154+
return
154155
}
155156
}
156157

@@ -202,7 +203,7 @@ async function removeModules(modules: string[], { skipInstall = false, skipConfi
202203

203204
if (isCancel(alsoRemove)) {
204205
cancel('Aborted.')
205-
return false
206+
return
206207
}
207208

208209
if (alsoRemove) {
@@ -249,22 +250,21 @@ function resolveModuleName(input: string, modulesDB: NuxtModule[], installed: Se
249250
|| m.aliases?.includes(input),
250251
)
251252

252-
return matched?.npm || input
253+
return matched?.npm ? basePackageName(matched.npm) : input
253254
}
254255

255256
async function findOrphanedPeers(removing: string[], projectPkg: PackageJson, cwd: string): Promise<OrphanedPeer[]> {
256257
const projectDeps = getProjectDependencies(projectPkg)
257258
const removingSet = new Set(removing)
258259

259-
// peer name -> first removed module that declares it
260260
const candidates = new Map<string, string>()
261261
for (const m of removing) {
262262
const pkg = await readDependencyPackageJson(m, cwd)
263263
if (!pkg?.peerDependencies) {
264264
continue
265265
}
266266
for (const peer of Object.keys(pkg.peerDependencies)) {
267-
if (!projectDeps.has(peer) || removingSet.has(peer) || candidates.has(peer)) {
267+
if (pkg.peerDependenciesMeta?.[peer]?.optional || !projectDeps.has(peer) || removingSet.has(peer) || candidates.has(peer)) {
268268
continue
269269
}
270270
candidates.set(peer, m)
@@ -275,13 +275,10 @@ async function findOrphanedPeers(removing: string[], projectPkg: PackageJson, cw
275275
return []
276276
}
277277

278-
// Strike out peers that another retained dep still needs
279278
const stillNeeded = new Set<string>()
280-
for (const dep of projectDeps) {
281-
if (removingSet.has(dep) || candidates.has(dep)) {
282-
continue
283-
}
284-
const depPkg = await readDependencyPackageJson(dep, cwd)
279+
const retained = [...projectDeps].filter(dep => !removingSet.has(dep) && !candidates.has(dep))
280+
const packages = await Promise.all(retained.map(dep => readDependencyPackageJson(dep, cwd)))
281+
for (const depPkg of packages) {
285282
if (!depPkg) {
286283
continue
287284
}

packages/nuxt-cli/src/commands/module/search.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,21 @@ export default defineCommand({
4141
},
4242
},
4343
async setup(ctx) {
44-
const nuxtVersion = await getNuxtVersion(ctx.args.cwd).catch(() => DEFAULT_NUXT_VERSION)
44+
const nuxtVersion = ctx.args.nuxtVersion
45+
? normalizeNuxtVersion(ctx.args.nuxtVersion)
46+
: await getNuxtVersion(ctx.args.cwd).catch(() => DEFAULT_NUXT_VERSION)
4547
return findModuleByKeywords(ctx.args._.join(' '), nuxtVersion)
4648
},
4749
})
4850

51+
export function normalizeNuxtVersion(version: string): string {
52+
return /^\d+$/.test(version)
53+
? `${version}.0.0`
54+
: /^\d+\.\d+$/.test(version)
55+
? `${version}.0`
56+
: version
57+
}
58+
4959
async function findModuleByKeywords(query: string, nuxtVersion: string) {
5060
const allModules = await fetchModules().catch((err) => {
5161
logNetworkError(err, { url: MODULES_API_URL })
@@ -84,7 +94,7 @@ async function findModuleByKeywords(query: string, nuxtVersion: string) {
8494
delete res.homepage
8595
}
8696
if (item.name === item.npm) {
87-
delete res.packageName
97+
delete res.package
8898
}
8999
return res
90100
})

0 commit comments

Comments
 (0)