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
import type { PackageJson } from 'pkg-types'
22

3+
import type { InstallResult } from '../utils/install'
4+
35
import { existsSync } from 'node:fs'
46
import process from '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
import colors from '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
const versionType = ctx.args.channel === 'nightly' ? 'nightly' : `latest ${ctx.args.channel}`
190193

191-
const spin = spinner()
192-
spin.start('Upgrading Nuxt')
193-
194-
await tasks([
195-
{
196-
title: `Installing ${versionType} Nuxt ${nuxtVersion} release`,
197-
task: async () => {
198-
await addDependency(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-
await dedupeDependencies({ recreateLockfile: true })
212-
return 'Lockfile recreated'
213-
},
214-
}]
215-
: []),
216-
...(method === 'dedupe'
217-
? [{
218-
title: 'Deduping dependencies',
219-
task: async () => {
220-
await dedupeDependencies()
221-
return 'Dependencies deduped'
222-
},
223-
}]
224-
: []),
225-
{
226-
title: 'Cleaning up build directories',
227-
task: async () => {
228-
let buildDir: string = '.nuxt'
229-
try {
230-
const { loadNuxtConfig } = await loadKit(cwd)
231-
const nuxtOptions = await loadNuxtConfig({ cwd })
232-
buildDir = nuxtOptions.buildDir
233-
}
234-
catch {
235-
// Use default buildDir (.nuxt)
236-
}
237-
await cleanupNuxtDirs(cwd, buildDir)
238-
return 'Build directories cleaned'
239-
},
240-
},
241-
])
242-
243-
spin.stop()
194+
const verbose = ctx.args.logLevel === 'verbose' || Boolean(process.env.DEBUG)
195+
196+
const installFailed = await withInstallSpinner(
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+
const recreateLockfile = method === 'force'
216+
const failed = await withInstallSpinner(
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+
const cleanupSpinner = spinner()
229+
cleanupSpinner.start('Cleaning up build directories')
230+
let buildDir: string = '.nuxt'
231+
try {
232+
const { loadNuxtConfig } = await loadKit(cwd)
233+
const nuxtOptions = await loadNuxtConfig({ cwd })
234+
buildDir = nuxtOptions.buildDir
235+
}
236+
catch {
237+
// Use default buildDir (.nuxt)
238+
}
239+
await cleanupNuxtDirs(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+
interface InstallHooks {
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+
async function withInstallSpinner(
287+
title: string,
288+
success: string,
289+
options: { verbose: boolean },
290+
run: (hooks: InstallHooks) => Promise<InstallResult>,
291+
): Promise<boolean> {
292+
const controller = new AbortController()
293+
const installLog = createInstallLog({ verbose: options.verbose })
294+
const spin = spinner({
295+
indicator: 'timer',
296+
onCancel: () => controller.abort(),
297+
})
298+
299+
spin.start(title)
300+
const result = await run({
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+
const ignoredBuilds = 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
export function findLockFile(cwd: string, workspaceDir: string, lockFiles: string | Array<string> | undefined) {
281325
const candidates = typeof lockFiles === '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
import process from '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
import colors from '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
return await execute(command, commandArgs, options)
108108
}
109109

110+
export interface DedupeOptions extends Omit<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+
export async function runDedupe(options: DedupeOptions): Promise<InstallResult> {
120+
const { exec } = await dedupeDependencies({
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+
const args = [...exec.args, ...nonInteractiveArgs(options.packageManager)]
132+
const [command, commandArgs] = await withCorepack(exec.command, args)
133+
134+
return await execute(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-
export async function cleanupNuxtDirs(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+
export async function cleanupNuxtDirs(rootDir: string, buildDir: string, options: { silent?: boolean } = {}) {
25+
if (!options.silent) {
26+
logger.info('Cleaning up generated Nuxt files and caches...')
27+
}
2528

2629
await rmRecursive(
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
import process from '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+
async function createFakePackageManager(script: string[] = ['#!/bin/sh', 'echo "all done"']) {
12+
const dir = await mkdtemp(join(tmpdir(), 'nuxt-install-test-'))
13+
const command = join(dir, 'fake-package-manager')
14+
await writeFile(command, script.join('\n'))
15+
await chmod(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 } = await createFakePackageManager()
125+
126+
const lines: string[] = []
127+
const result = await runDedupe({
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 } = await createFakePackageManager()
140+
const lockFile = join(dir, 'pnpm-lock.yaml')
141+
await writeFile(lockFile, 'lockfileVersion: 9.0\n')
142+
143+
const result = await runDedupe({
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+
const result = await runDedupe({
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)