Skip to content

Commit a04ec72

Browse files
committed
fix(upgrade): take ownership of dependency installation
1 parent 846ebd5 commit a04ec72

4 files changed

Lines changed: 185 additions & 59 deletions

File tree

‎packages/nuxt-cli/src/commands/upgrade.ts‎

Lines changed: 99 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
importtype{PackageJson}from'pkg-types'
22

3+
importtype{InstallResult}from'../utils/install'
4+
35
import{existsSync}from'node:fs'
46
importprocessfrom'node:process'
57

6-
import{cancel,intro,isCancel,note,outro,select,spinner,tasks}from'@clack/prompts'
8+
import{cancel,intro,isCancel,note,outro,select,spinner}from'@clack/prompts'
79
import{defineCommand}from'citty'
8-
import{addDependency,dedupeDependencies,detectPackageManager}from'nypm'
10+
import{detectPackageManager}from'nypm'
911
import{dirname,relative,resolve}from'pathe'
1012
importcolorsfrom'picocolors'
1113
import{findWorkspaceDir,readPackageJSON}from'pkg-types'
1214

15+
import{createInstallLog,runDedupe,runInstall,takeUnreportedIgnoredBuilds}from'../utils/install'
1316
import{loadKit}from'../utils/kit'
1417
import{logger}from'../utils/logger'
1518
import{cleanupNuxtDirs,nuxtVersionToGitIdentifier}from'../utils/nuxt'
@@ -188,59 +191,53 @@ export default defineCommand({
188191

189192
constversionType=ctx.args.channel==='nightly' ? 'nightly' : `latest ${ctx.args.channel}`
190193

191-
constspin=spinner()
192-
spin.start('Upgrading Nuxt')
193-
194-
awaittasks([
195-
{
196-
title: `Installing ${versionType} Nuxt ${nuxtVersion} release`,
197-
task: async()=>{
198-
awaitaddDependency(npmPackages,{
199-
cwd,
200-
packageManager,
201-
dev: nuxtDependencyType==='devDependencies',
202-
workspace: packageManager?.name==='pnpm'&&existsSync(resolve(cwd,'pnpm-workspace.yaml')),
203-
})
204-
return'Nuxt packages installed'
205-
},
206-
},
207-
...(method==='force'
208-
? [{
209-
title: `Recreating ${forceRemovals}`,
210-
task: async()=>{
211-
awaitdedupeDependencies({recreateLockfile: true})
212-
return'Lockfile recreated'
213-
},
214-
}]
215-
: []),
216-
...(method==='dedupe'
217-
? [{
218-
title: 'Deduping dependencies',
219-
task: async()=>{
220-
awaitdedupeDependencies()
221-
return'Dependencies deduped'
222-
},
223-
}]
224-
: []),
225-
{
226-
title: 'Cleaning up build directories',
227-
task: async()=>{
228-
letbuildDir: string='.nuxt'
229-
try{
230-
const{ loadNuxtConfig }=awaitloadKit(cwd)
231-
constnuxtOptions=awaitloadNuxtConfig({ cwd })
232-
buildDir=nuxtOptions.buildDir
233-
}
234-
catch{
235-
// Use default buildDir (.nuxt)
236-
}
237-
awaitcleanupNuxtDirs(cwd,buildDir)
238-
return'Build directories cleaned'
239-
},
240-
},
241-
])
242-
243-
spin.stop()
194+
constverbose=ctx.args.logLevel==='verbose'||Boolean(process.env.DEBUG)
195+
196+
constinstallFailed=awaitwithInstallSpinner(
197+
`Installing ${versionType} Nuxt ${nuxtVersion} release`,
198+
'Nuxt packages installed',
199+
{ verbose },
200+
hooks=>runInstall({
201+
cwd,
202+
packageManager,
203+
dependencies: npmPackages,
204+
dev: nuxtDependencyType==='devDependencies',
205+
workspace: packageManager.name==='pnpm'&&existsSync(resolve(cwd,'pnpm-workspace.yaml')),
206+
...hooks,
207+
}),
208+
)
209+
210+
if(installFailed){
211+
process.exit(1)
212+
}
213+
214+
if(method==='force'||method==='dedupe'){
215+
constrecreateLockfile=method==='force'
216+
constfailed=awaitwithInstallSpinner(
217+
recreateLockfile ? `Recreating ${forceRemovals}` : 'Deduping dependencies',
218+
recreateLockfile ? 'Lockfile recreated' : 'Dependencies deduped',
219+
{ verbose },
220+
hooks=>runDedupe({ cwd, packageManager, recreateLockfile, ...hooks}),
221+
)
222+
223+
if(failed){
224+
process.exit(1)
225+
}
226+
}
227+
228+
constcleanupSpinner=spinner()
229+
cleanupSpinner.start('Cleaning up build directories')
230+
letbuildDir: string='.nuxt'
231+
try{
232+
const{ loadNuxtConfig }=awaitloadKit(cwd)
233+
constnuxtOptions=awaitloadNuxtConfig({ cwd })
234+
buildDir=nuxtOptions.buildDir
235+
}
236+
catch{
237+
// Use default buildDir (.nuxt)
238+
}
239+
awaitcleanupNuxtDirs(cwd,buildDir,{silent: true})
240+
cleanupSpinner.stop('Build directories cleaned')
244241

245242
if(method==='force'){
246243
logger.info(`If you encounter any issues, revert the changes and try with ${colors.cyan('--no-force')}`)
@@ -276,6 +273,53 @@ export default defineCommand({
276273
},
277274
})
278275

276+
interfaceInstallHooks{
277+
onOutput: (line: string)=>void
278+
onStatus: (message: string)=>void
279+
signal: AbortSignal
280+
}
281+
282+
/**
283+
* Run a package manager step behind a spinner, surfacing its output only when it
284+
* fails (or when running verbosely). Returns whether the step failed.
285+
*/
286+
asyncfunctionwithInstallSpinner(
287+
title: string,
288+
success: string,
289+
options: {verbose: boolean},
290+
run: (hooks: InstallHooks)=>Promise<InstallResult>,
291+
): Promise<boolean>{
292+
constcontroller=newAbortController()
293+
constinstallLog=createInstallLog({verbose: options.verbose})
294+
constspin=spinner({
295+
indicator: 'timer',
296+
onCancel: ()=>controller.abort(),
297+
})
298+
299+
spin.start(title)
300+
constresult=awaitrun({
301+
onOutput: installLog.onOutput,
302+
onStatus: message=>spin.message(message),
303+
signal: controller.signal,
304+
})
305+
306+
if(result.success){
307+
spin.stop(success)
308+
}
309+
else{
310+
spin.error(result.error??`${title} failed`)
311+
}
312+
313+
installLog.finish(result)
314+
315+
constignoredBuilds=takeUnreportedIgnoredBuilds(result.ignoredBuilds)
316+
if(ignoredBuilds.length>0){
317+
logger.warn(`Build scripts were not run for ${ignoredBuilds.map(name=>colors.cyan(name)).join(', ')}.`)
318+
}
319+
320+
return!result.success
321+
}
322+
279323
// Find which lock file is in use since `nypm.detectPackageManager` doesn't return this
280324
exportfunctionfindLockFile(cwd: string,workspaceDir: string,lockFiles: string|Array<string>|undefined){
281325
constcandidates=typeoflockFiles==='string' ? [lockFiles] : lockFiles

‎packages/nuxt-cli/src/utils/install.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { delimiter, resolve } from 'node:path'
88
importprocessfrom'node:process'
99

1010
import{log,S_BAR}from'@clack/prompts'
11-
import{addDependency,installDependencies,packageManagers}from'nypm'
11+
import{addDependency,dedupeDependencies,installDependencies,packageManagers}from'nypm'
1212
importcolorsfrom'picocolors'
1313
import{provider}from'std-env'
1414
import{normalizeSpawnCommand,x}from'tinyexec'
@@ -107,6 +107,33 @@ export async function runInstall(options: InstallOptions): Promise<InstallResult
107107
returnawaitexecute(command,commandArgs,options)
108108
}
109109

110+
exportinterfaceDedupeOptionsextendsOmit<InstallOptions,'dependencies'|'dev'|'workspace'>{
111+
/** Delete the lockfile and resolve dependencies from scratch. */
112+
recreateLockfile?: boolean
113+
}
114+
115+
/**
116+
* Dedupe a project's dependencies, or recreate its lockfile, with the same quiet
117+
* output handling as {@link runInstall}.
118+
*/
119+
exportasyncfunctionrunDedupe(options: DedupeOptions): Promise<InstallResult>{
120+
const{ exec }=awaitdedupeDependencies({
121+
cwd: options.cwd,
122+
packageManager: options.packageManager,
123+
recreateLockfile: options.recreateLockfile,
124+
dry: true,
125+
})
126+
127+
if(!exec){
128+
return{success: true,output: '',command: '',ignoredBuilds: []}
129+
}
130+
131+
constargs=[...exec.args, ...nonInteractiveArgs(options.packageManager)]
132+
const[command,commandArgs]=awaitwithCorepack(exec.command,args)
133+
134+
returnawaitexecute(command,commandArgs,options)
135+
}
136+
110137
/**
111138
* Flags that stop a package manager from asking a question we cannot answer, or
112139
* from failing on something the user can act on after the fact.

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ interface NuxtProjectManifest {
2020
}
2121
}
2222

23-
exportasyncfunctioncleanupNuxtDirs(rootDir: string,buildDir: string){
24-
logger.info('Cleaning up generated Nuxt files and caches...')
23+
/** `silent` is for callers that already report progress themselves. */
24+
exportasyncfunctioncleanupNuxtDirs(rootDir: string,buildDir: string,options: {silent?: boolean}={}){
25+
if(!options.silent){
26+
logger.info('Cleaning up generated Nuxt files and caches...')
27+
}
2528

2629
awaitrmRecursive(
2730
[

‎packages/nuxt-cli/test/unit/utils/install.spec.ts‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import{existsSync}from'node:fs'
12
import{chmod,mkdtemp,writeFile}from'node:fs/promises'
23
import{tmpdir}from'node:os'
34
import{join}from'node:path'
45
importprocessfrom'node:process'
56

67
import{describe,expect,it}from'vitest'
78

8-
import{getIgnoredBuilds,isExecutableAvailable,nonInteractiveArgs,runInstall,takeUnreportedIgnoredBuilds}from'../../../src/utils/install'
9+
import{getIgnoredBuilds,isExecutableAvailable,nonInteractiveArgs,runDedupe,runInstall,takeUnreportedIgnoredBuilds}from'../../../src/utils/install'
10+
11+
asyncfunctioncreateFakePackageManager(script: string[]=['#!/bin/sh','echo "all done"']){
12+
constdir=awaitmkdtemp(join(tmpdir(),'nuxt-install-test-'))
13+
constcommand=join(dir,'fake-package-manager')
14+
awaitwriteFile(command,script.join('\n'))
15+
awaitchmod(command,0o755)
16+
return{ dir, command }
17+
}
918

1019
describe('nonInteractiveArgs',()=>{
1120
it('should opt pnpm out of prompts and strict dep builds',()=>{
@@ -109,3 +118,46 @@ describe('runInstall', () => {
109118
expect(result.command).toBe('nuxt-cli-nonexistent-package-manager install')
110119
})
111120
})
121+
122+
describe('runDedupe',()=>{
123+
it.skipIf(process.platform==='win32')('should dedupe without printing the package manager output',async()=>{
124+
const{ dir, command }=awaitcreateFakePackageManager()
125+
126+
constlines: string[]=[]
127+
constresult=awaitrunDedupe({
128+
cwd: dir,
129+
packageManager: {name: 'pnpm', command },
130+
onOutput: line=>lines.push(line),
131+
})
132+
133+
expect(result.success).toBe(true)
134+
expect(result.command).toBe(`${command} dedupe --config.confirm-modules-purge=false --config.strict-dep-builds=false`)
135+
expect(lines).toEqual(['all done'])
136+
})
137+
138+
it.skipIf(process.platform==='win32')('should install after removing the lockfile when recreating it',async()=>{
139+
const{ dir, command }=awaitcreateFakePackageManager()
140+
constlockFile=join(dir,'pnpm-lock.yaml')
141+
awaitwriteFile(lockFile,'lockfileVersion: 9.0\n')
142+
143+
constresult=awaitrunDedupe({
144+
cwd: dir,
145+
packageManager: {name: 'pnpm', command,lockFile: 'pnpm-lock.yaml'},
146+
recreateLockfile: true,
147+
})
148+
149+
expect(result.success).toBe(true)
150+
expect(result.command).toContain(`${command} install`)
151+
expect(existsSync(lockFile)).toBe(false)
152+
})
153+
154+
it('should report a missing package manager instead of throwing',async()=>{
155+
constresult=awaitrunDedupe({
156+
cwd: process.cwd(),
157+
packageManager: {name: 'npm',command: 'nuxt-cli-nonexistent-package-manager'},
158+
})
159+
160+
expect(result.success).toBe(false)
161+
expect(result.missingPackageManager).toBe(true)
162+
})
163+
})

0 commit comments

Comments
 (0)