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
exportasyncfunctionselectModulesAutocomplete(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
constsortedModules=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
returna.npm.localeCompare(b.npm)
4136
})
4237

43-
// Setup fzf for fast fuzzy search
4438
constfzf=newFzf(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
constclackOptions: 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+
constmatches=newMap<string,Set<string>>()
5851
constfilter=(search: string,option: Option<string>): boolean=>{
5952
if(!search)
6053
returntrue
61-
constresults=fzf.find(search)
62-
returnresults.some(r=>r.item.npm===option.value)
54+
letresults=matches.get(search)
55+
if(!results){
56+
results=newSet(fzf.find(search).map(r=>r.item.npm))
57+
matches.set(search,results)
58+
}
59+
returnresults.has(option.value)
6360
}
6461

6562
constresult=awaitautocompleteMultiselect({
@@ -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+
returnisCancel(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-
exportconstcategories=[
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
interfaceNuxtApiModulesResponse{
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: (typeofcategories)[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{runCommandDefasrunCommand}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+
letmodulesDB: NuxtModule[]
9292
if(modules.length===0){
9393
constmodulesSpinner=spinner()
9494
modulesSpinner.start('Fetching available modules')
@@ -102,28 +102,36 @@ export function defineAddCommand({ layers = false }: { layers?: boolean } = {})
102102
getNuxtVersion(cwd),
103103
])
104104

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

109110
modulesSpinner.stop('Modules loaded')
110111

111-
constresult=awaitselectModulesAutocomplete({
112+
constselection=awaitselectModulesAutocomplete({
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=awaitfetchModules().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+
letnuxtVersionPromise: Promise<string>|undefined
131+
constgetProjectNuxtVersion=()=>nuxtVersionPromise||=getNuxtVersion(cwd)
124132
constresolvedModules: ResolvedModule[]=[]
125-
for(constmoduleNameofmodules){
126-
constresolvedModule=awaitresolveModule(moduleName,cwd)
133+
for(constmoduleNameofnewSet(modules)){
134+
constresolvedModule=awaitresolveModule(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
awaitrunCommand(prepareCommand,forwardCommandArgs(ctx.args))
148155
}
@@ -154,7 +161,6 @@ export default defineAddCommand()
154161

155162
// -- Internal Utils --
156163
asyncfunctionaddModules(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
constinstalledModules: ResolvedModule[]=[]
160166
constnotInstalledModules: 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
letconfig=awaitreadNuxtConfig(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')}: ${(errorasError).message}`)
260265
logger.error(`Please manually add ${styleText('cyan',modules.map(module=>module.specifier).join(', '))} to ${styleText('cyan','nuxt.config.ts')}`)
266+
returnfalse
261267
}
262268
}
263269

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

345-
asyncfunctionresolveModule(moduleName: string,cwd: string): Promise<ModuleResolution>{
351+
asyncfunctionresolveModule(moduleName: string,cwd: string,modulesDB: NuxtModule[],getProjectNuxtVersion: ()=>Promise<string>): Promise<ModuleResolution>{
346352
constspec=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
letsubpath=spec.subpath
355361

356-
constmodulesDB=awaitfetchModules().catch((err)=>{
357-
logNetworkError(err,{url: MODULES_API_URL,level: 'warn',prefix: 'Cannot search in the Nuxt Modules database.'})
358-
return[]
359-
})
360-
361362
constbareName=subpath ? `${pkgName}/${subpath}` : pkgName
362363
constmatchedModule=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-
constnuxtVersion=awaitgetNuxtVersion(cwd)
381+
constnuxtVersion=awaitgetProjectNuxtVersion()
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
constpkg=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
constinstalledNames=getProjectDependencies(projectPkg)
7068

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

8583
constproceed=awaitremoveModules(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
awaitrunCommand(prepareCommand,forwardCommandArgs(ctx.args))
9491
}
9592
},
9693
})
9794

9895
// -- Internal Utils --
99-
asyncfunctionremoveModules(modules: string[],{ skipInstall =false, skipConfig =false, cwd }: {skipInstall?: boolean,skipConfig?: boolean,cwd: string},projectPkg: PackageJson): Promise<boolean>{
96+
asyncfunctionremoveModules(modules: string[],{ skipInstall =false, skipConfig =false, cwd }: {skipInstall?: boolean,skipConfig?: boolean,cwd: string},projectPkg: PackageJson): Promise<boolean|undefined>{
10097
constremovedFromConfig: string[]=[]
10198
constdependencies=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-
returnfalse
118+
return
122119
}
123120

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

143-
awaitremoveNuxtConfigEntries(config,doomed).catch((error)=>{
140+
try{
141+
awaitremoveNuxtConfigEntries(config,doomed)
142+
}
143+
catch(error){
144144
logger.error(`Failed to update ${styleText('cyan','nuxt.config')}: ${(errorasError).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+
returnfalse
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-
returnfalse
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-
returnfalse
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-
returnmatched?.npm||input
253+
returnmatched?.npm? basePackageName(matched.npm) :input
253254
}
254255

255256
asyncfunctionfindOrphanedPeers(removing: string[],projectPkg: PackageJson,cwd: string): Promise<OrphanedPeer[]>{
256257
constprojectDeps=getProjectDependencies(projectPkg)
257258
constremovingSet=newSet(removing)
258259

259-
// peer name -> first removed module that declares it
260260
constcandidates=newMap<string,string>()
261261
for(constmofremoving){
262262
constpkg=awaitreadDependencyPackageJson(m,cwd)
263263
if(!pkg?.peerDependencies){
264264
continue
265265
}
266266
for(constpeerofObject.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
conststillNeeded=newSet<string>()
280-
for(constdepofprojectDeps){
281-
if(removingSet.has(dep)||candidates.has(dep)){
282-
continue
283-
}
284-
constdepPkg=awaitreadDependencyPackageJson(dep,cwd)
279+
constretained=[...projectDeps].filter(dep=>!removingSet.has(dep)&&!candidates.has(dep))
280+
constpackages=awaitPromise.all(retained.map(dep=>readDependencyPackageJson(dep,cwd)))
281+
for(constdepPkgofpackages){
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
asyncsetup(ctx){
44-
constnuxtVersion=awaitgetNuxtVersion(ctx.args.cwd).catch(()=>DEFAULT_NUXT_VERSION)
44+
constnuxtVersion=ctx.args.nuxtVersion
45+
? normalizeNuxtVersion(ctx.args.nuxtVersion)
46+
: awaitgetNuxtVersion(ctx.args.cwd).catch(()=>DEFAULT_NUXT_VERSION)
4547
returnfindModuleByKeywords(ctx.args._.join(' '),nuxtVersion)
4648
},
4749
})
4850

51+
exportfunctionnormalizeNuxtVersion(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
asyncfunctionfindModuleByKeywords(query: string,nuxtVersion: string){
5060
constallModules=awaitfetchModules().catch((err)=>{
5161
logNetworkError(err,{url: MODULES_API_URL})
@@ -84,7 +94,7 @@ async function findModuleByKeywords(query: string, nuxtVersion: string) {
8494
deleteres.homepage
8595
}
8696
if(item.name===item.npm){
87-
deleteres.packageName
97+
deleteres.package
8898
}
8999
returnres
90100
})

0 commit comments

Comments
 (0)