From 266b60c4e6d1fa259b15c272aa3a233f53d37cb3 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 29 Sep 2026 12:55:21 +0200 Subject: [PATCH 1/2] fix: migrate to `detect-package-manager` --- packages/create-nuxt/package.json | 2 +- packages/create-nuxt/src/init.ts | 77 +++------- packages/create-nuxt/test/unit/init.spec.ts | 39 +---- packages/nuxt-cli/package.json | 2 +- packages/nuxt-cli/src/commands/info.ts | 5 +- .../nuxt-cli/src/commands/module/_utils.ts | 8 - packages/nuxt-cli/src/commands/module/add.ts | 39 ++--- .../nuxt-cli/src/commands/module/remove.ts | 21 +-- packages/nuxt-cli/src/commands/typecheck.ts | 29 ++-- packages/nuxt-cli/src/commands/upgrade.ts | 8 +- packages/nuxt-cli/src/dev/preflight.ts | 14 +- packages/nuxt-cli/src/utils/headless.ts | 10 +- packages/nuxt-cli/src/utils/install.ts | 137 ++++-------------- .../nuxt-cli/src/utils/package-managers.ts | 34 +++++ .../nuxt-cli/src/utils/packageManagers.ts | 13 -- .../nuxt-cli/test/unit/commands/add.spec.ts | 25 ++-- .../unit/commands/module/add-config.spec.ts | 6 +- .../test/unit/commands/module/add.spec.ts | 24 ++- .../test/unit/commands/module/remove.spec.ts | 62 ++++---- .../test/unit/commands/typecheck.spec.ts | 24 +-- .../test/unit/commands/upgrade-run.spec.ts | 8 +- .../nuxt-cli/test/unit/utils/install.spec.ts | 119 +++++++-------- .../test/unit/utils/package-managers.spec.ts | 48 ++++++ .../test/unit/utils/packageManagers.spec.ts | 15 -- packages/nuxt-cli/tsdown.config.ts | 2 +- pnpm-lock.yaml | 12 +- 26 files changed, 326 insertions(+), 457 deletions(-) create mode 100644 packages/nuxt-cli/src/utils/package-managers.ts delete mode 100644 packages/nuxt-cli/src/utils/packageManagers.ts create mode 100644 packages/nuxt-cli/test/unit/utils/package-managers.spec.ts delete mode 100644 packages/nuxt-cli/test/unit/utils/packageManagers.spec.ts diff --git a/packages/create-nuxt/package.json b/packages/create-nuxt/package.json index 197fc48f8..640752331 100644 --- a/packages/create-nuxt/package.json +++ b/packages/create-nuxt/package.json @@ -35,7 +35,7 @@ "@types/node": "^24.19.0", "citty": "^0.2.2", "giget": "^3.3.1", - "nypm": "^0.6.10", + "package-manager-detector": "^1.8.0", "pathe": "^2.0.3", "pkg-types": "^2.3.3", "std-env": "^4.2.0", diff --git a/packages/create-nuxt/src/init.ts b/packages/create-nuxt/src/init.ts index ba22bf953..b6aafd520 100644 --- a/packages/create-nuxt/src/init.ts +++ b/packages/create-nuxt/src/init.ts @@ -1,10 +1,10 @@ import type { ArgsDef, CommandDef } from 'citty' import type { DownloadTemplateResult } from 'giget' -import type { PackageManagerName } from 'nypm' +import type { AgentName } from 'package-manager-detector' import type { InstallResult } from '../../nuxt-cli/src/utils/install' import type { TemplateData } from '../../nuxt-cli/src/utils/starter-templates' -import { existsSync } from 'node:fs' +import { existsSync } from 'node:fs' import { writeFile } from 'node:fs/promises' import process from 'node:process' import { styleText } from 'node:util' @@ -12,7 +12,6 @@ import { styleText } from 'node:util' import { cancel, confirm, intro, isCancel, outro, S_BAR, select, text } from '@clack/prompts' import { defineCommand, showUsage } from 'citty' import { downloadTemplate, startShell } from 'giget' -import { detectPackageManager } from 'nypm' import { basename, join, relative, resolve } from 'pathe' import { findFile, readPackageJSON, writePackageJSON } from 'pkg-types' import { hasTTY } from 'std-env' @@ -26,9 +25,10 @@ import { runCommandDef as runCommand } from '../../nuxt-cli/src/run-command' import { nuxtIcon } from '../../nuxt-cli/src/utils/ascii' import { fetchJson } from '../../nuxt-cli/src/utils/fetch' import { formatHeadlessCommand } from '../../nuxt-cli/src/utils/headless' -import { createInstallLog, resolvePackageManagerDescriptor, runInstall, takeUnreportedIgnoredBuilds } from '../../nuxt-cli/src/utils/install' +import { createInstallLog, runInstall, takeUnreportedIgnoredBuilds } from '../../nuxt-cli/src/utils/install' import { debug, logger } from '../../nuxt-cli/src/utils/logger' import { classifyNetworkError, describeNetworkError, logNetworkError, probeNetworkError } from '../../nuxt-cli/src/utils/network' +import { detectPackageManager, isPackageManagerName, packageManagerNames } from '../../nuxt-cli/src/utils/package-managers' import { relativeToProcess } from '../../nuxt-cli/src/utils/paths' import { createSpinner } from '../../nuxt-cli/src/utils/spinner' import { getTemplates, TEMPLATES_API_URL } from '../../nuxt-cli/src/utils/starter-templates' @@ -43,19 +43,6 @@ const DEFAULT_REGISTRY = 'https://raw.githubusercontent.com/nuxt/starter/templat const DEFAULT_TEMPLATE_NAME = 'minimal' const NIGHTLY_DIST_TAGS_URL = 'https://registry.npmjs.org/nuxt-nightly' -const pms: Record = { - npm: undefined, - pnpm: undefined, - yarn: undefined, - bun: undefined, - deno: undefined, - aube: undefined, - nub: undefined, -} - -// this is for type safety to prompt updating code in nuxi when nypm adds a new package manager -const packageManagerOptions = Object.keys(pms) as PackageManagerName[] - // Arguments that would otherwise be gathered through interactive prompts, // so they must be explicitly provided when no TTY is available const nonInteractiveRequiredArgs = ['dir', 'template', 'packageManager', 'gitInit'] as const @@ -120,7 +107,7 @@ export function getNextSteps(options: { installFailure?: unknown installSkipped?: boolean recoveryCommands: string[] - packageManager: PackageManagerName + packageManager: AgentName }): string[] { const { dir, shell, installFailure, installSkipped, recoveryCommands, packageManager } = options const runCmd = packageManager === 'deno' ? 'task' : 'run' @@ -186,7 +173,7 @@ export default defineCommand({ packageManager: { type: 'string', description: 'Package manager choice', - valueHint: packageManagerOptions.join('|'), + valueHint: packageManagerNames.join('|'), }, modules: { type: 'string', @@ -207,8 +194,8 @@ export default defineCommand({ // banner or network work) so a typo fails fast with a clear message instead // of being silently ignored once a template's own package manager is // detected. - if (ctx.args.packageManager && !packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName)) { - logger.error(`Invalid package manager: ${styleText('cyan', ctx.args.packageManager)}. Choose one of ${packageManagerOptions.map(pm => styleText('cyan', pm)).join(', ')}.`) + if (ctx.args.packageManager && !isPackageManagerName(ctx.args.packageManager)) { + logger.error(`Invalid package manager: ${styleText('cyan', ctx.args.packageManager)}. Choose one of ${packageManagerNames.map(pm => styleText('cyan', pm)).join(', ')}.`) process.exit(ARG_ERROR_EXIT_CODE) } @@ -266,7 +253,7 @@ export default defineCommand({ if (ctx.args.template) { return false } - return !packageManagerOptions.includes(ctx.args.packageManager as PackageManagerName) + return !isPackageManagerName(ctx.args.packageManager) } return ctx.args[name] === undefined || ctx.args[name] === '' }) @@ -484,8 +471,8 @@ export default defineCommand({ const recoveryCommands: string[] = [] const currentPackageManager = detectCurrentPackageManager() - const packageManagerArg = ctx.args.packageManager as PackageManagerName - const packageManagerSelectOptions = packageManagerOptions.map(pm => ({ + const packageManagerArg = ctx.args.packageManager + const packageManagerSelectOptions = packageManagerNames.map(pm => ({ label: pm, value: pm, hint: currentPackageManager === pm ? 'current' : undefined, @@ -497,16 +484,16 @@ export default defineCommand({ // workspace config (e.g. `pnpm-workspace.yaml`) behind and silently break // the project. Shipping a template that works across package managers (i.e. // without a lockfile) is left to the template author. - const templatePackageManager = await detectTemplatePackageManager(template.dir) + const templatePackageManager = await detectPackageManager(template.dir, { includeParentDirs: false }) - let selectedPackageManager: PackageManagerName + let selectedPackageManager: AgentName // Set when an explicit `--packageManager` conflicts with the template's pin: // installing would run the requested package manager against the template's // lockfile and workspace config for a different one, leaving a broken // project. We won't mutate the template, so we scaffold it as-is and skip // the install, letting the user reconcile the package manager themselves. let skipInstallOnConflict = false - if (packageManagerOptions.includes(packageManagerArg)) { + if (isPackageManagerName(packageManagerArg)) { selectedPackageManager = packageManagerArg if (templatePackageManager && templatePackageManager.name !== packageManagerArg) { skipInstallOnConflict = true @@ -578,10 +565,9 @@ export default defineCommand({ const result = await runInstall({ cwd: template.dir, - packageManager: resolvePackageManagerDescriptor( - selectedPackageManager, - templatePackageManager?.name === selectedPackageManager ? templatePackageManager.version : undefined, - ), + packageManager: templatePackageManager?.name === selectedPackageManager + ? templatePackageManager + : { name: selectedPackageManager, agent: selectedPackageManager }, onOutput: installLog.onOutput, onStatus: message => installSpinner.message(message), signal: installController.signal, @@ -864,31 +850,6 @@ async function getTemplateDependencies(templateDir: string) { } } -export interface TemplatePackageManager { - name: PackageManagerName - version?: string -} - -/** - * Detect the package manager a template pins, scoped to the template directory - * (so we don't pick up the parent project's setup) via its lockfile, marker - * files or `packageManager` field. Returns `undefined` when the template pins - * none, in which case it is package-manager agnostic and the user is free to - * pick any. Detection errors are treated as "no pin". - */ -export async function detectTemplatePackageManager(templateDir: string): Promise { - const detected = await detectPackageManager(templateDir, { - includeParentDirs: false, - ignoreArgv: true, - }).catch(() => undefined) - - if (!detected) { - return - } - - return { name: detected.name, version: detected.version } -} - function isVerbose(logLevel?: string) { return logLevel === 'verbose' || Boolean(process.env.DEBUG) } @@ -899,7 +860,7 @@ function detectCurrentPackageManager() { return } const [name] = userAgent.split('/') - if (packageManagerOptions.includes(name as PackageManagerName)) { - return name as PackageManagerName + if (isPackageManagerName(name)) { + return name } } diff --git a/packages/create-nuxt/test/unit/init.spec.ts b/packages/create-nuxt/test/unit/init.spec.ts index 7c65b4c43..206cc8d95 100644 --- a/packages/create-nuxt/test/unit/init.spec.ts +++ b/packages/create-nuxt/test/unit/init.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { detectTemplatePackageManager, getNextSteps, useYarnNodeModulesLinker } from '../../src/init' +import { getNextSteps, useYarnNodeModulesLinker } from '../../src/init' describe('useYarnNodeModulesLinker', () => { let dir: string @@ -76,40 +76,3 @@ describe('getNextSteps', () => { expect(getNextSteps({ ...base, dir: 'my-app', packageManager: 'deno' })).toEqual(['cd my-app', 'deno task dev']) }) }) - -describe('detectTemplatePackageManager', () => { - let dir: string - - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'nuxt-init-test-')) - }) - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }) - }) - - async function writePkg(pkg: Record) { - await writeFile(join(dir, 'package.json'), JSON.stringify(pkg, null, 2)) - } - - it('detects the package manager from a lockfile', async () => { - await writePkg({ name: 'app' }) - await writeFile(join(dir, 'pnpm-lock.yaml'), '') - - expect((await detectTemplatePackageManager(dir))?.name).toBe('pnpm') - }) - - it('detects the package manager and version from the `packageManager` field', async () => { - await writePkg({ name: 'app', packageManager: 'yarn@4.0.0' }) - - const detected = await detectTemplatePackageManager(dir) - expect(detected?.name).toBe('yarn') - expect(detected?.version).toBe('4.0.0') - }) - - it('returns undefined when the template pins no package manager', async () => { - await writePkg({ name: 'app' }) - - expect(await detectTemplatePackageManager(dir)).toBeUndefined() - }) -}) diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 697849d54..226b72782 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -77,6 +77,7 @@ "get-port-please": "^3.2.0", "my-bad": "^0.2.9", "obug": "^3.0.0", + "package-manager-detector": "^1.8.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.3", @@ -102,7 +103,6 @@ "jiti": "^2.7.0", "nitro": "^3.0.260903-beta", "nitropack": "^2.13.4", - "nypm": "^0.6.10", "rolldown": "^1.2.11", "tsdown": "^0.23.0", "typescript": "^6.0.3", diff --git a/packages/nuxt-cli/src/commands/info.ts b/packages/nuxt-cli/src/commands/info.ts index 91672b30f..35e741af5 100644 --- a/packages/nuxt-cli/src/commands/info.ts +++ b/packages/nuxt-cli/src/commands/info.ts @@ -8,7 +8,6 @@ import { styleText } from 'node:util' import { box } from '@clack/prompts' import { defineCommand } from 'citty' -import { detectPackageManager } from 'nypm' import { readPackageJSON } from 'pkg-types' import { camelCase } from 'scule' import { isBun, isDeno, isMinimal } from 'std-env' @@ -23,7 +22,7 @@ import { logger } from '../utils/logger' import { resolveNitroVersion } from '../utils/nitro' import { getNuxtConfig } from '../utils/nuxt-config' import { readDependencyPackageJson } from '../utils/package-json' -import { getPackageManagerVersion } from '../utils/packageManagers' +import { detectPackageManager, getPackageManagerVersion } from '../utils/package-managers' import { resolveRootDir } from '../utils/paths' import { tryResolveNuxt } from '../utils/resolve-nuxt' import { rootDirArgs } from './_shared' @@ -96,7 +95,7 @@ export default defineCommand({ const moduleNames = modules.filter(module => module !== null) const builder = nuxtConfig.builder || 'vite' const packageManager = detectedPackageManager - ? `${detectedPackageManager.name}@${getPackageManagerVersion(detectedPackageManager.command)}` + ? `${detectedPackageManager.name}@${getPackageManagerVersion(detectedPackageManager.name)}` : 'unknown' const osType = os.type() const cpus = os.cpus() diff --git a/packages/nuxt-cli/src/commands/module/_utils.ts b/packages/nuxt-cli/src/commands/module/_utils.ts index bda528ca8..d899fc660 100644 --- a/packages/nuxt-cli/src/commands/module/_utils.ts +++ b/packages/nuxt-cli/src/commands/module/_utils.ts @@ -1,11 +1,7 @@ -import type { PackageManager } from 'nypm' import type { PackageJson } from 'pkg-types' -import { existsSync } from 'node:fs' - import { styleText } from 'node:util' import { confirm, isCancel } from '@clack/prompts' -import { resolve } from 'pathe' import { satisfies } from 'verkit' import { fetchJson } from '../../utils/fetch' @@ -240,10 +236,6 @@ export async function ensureNuxtDependency(cwd: string, projectPkg: PackageJson) return !isCancel(shouldContinue) && shouldContinue === true } -export function isPnpmWorkspace(packageManager: PackageManager | undefined, cwd: string): boolean { - return packageManager?.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')) -} - /** Forward `cwd` and log-level args to a chained command invocation. */ export function forwardCommandArgs(args: Record): string[] { return Object.entries(args) diff --git a/packages/nuxt-cli/src/commands/module/add.ts b/packages/nuxt-cli/src/commands/module/add.ts index e8cecf40c..b13b4c65e 100644 --- a/packages/nuxt-cli/src/commands/module/add.ts +++ b/packages/nuxt-cli/src/commands/module/add.ts @@ -1,6 +1,6 @@ -import type { PackageManager } from 'nypm' -import type { PackageJson } from 'pkg-types' +import type { DetectResult } from 'package-manager-detector' +import type { PackageJson } from 'pkg-types' import type { ConfigEntries } from '../../utils/config' import type { RegistryMeta } from '../../utils/registry' import type { NuxtModule } from './_utils' @@ -9,7 +9,6 @@ import process from 'node:process' import { styleText } from 'node:util' import { cancel, confirm, isCancel, select } from '@clack/prompts' import { defineCommand } from 'citty' -import { packageManagers } from 'nypm' import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' import { findMaxSatisfying, satisfies } from 'verkit' @@ -17,31 +16,20 @@ import { findMaxSatisfying, satisfies } from 'verkit' import { runCommandDef as runCommand } from '../../run-command' import { addNuxtConfigEntries, createNuxtConfig, readNuxtConfig } from '../../utils/config' import { fetchJson } from '../../utils/fetch' -import { createInstallLog, detectProjectPackageManager, resolvePackageManagerDescriptor, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install' +import { createInstallLog, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' +import { detectPackageManager, isPackageManagerName, packageManagerNames } from '../../utils/package-managers' import { detectNpmRegistry } from '../../utils/registry' import { createSpinner } from '../../utils/spinner' import { getNuxtVersion } from '../../utils/versions' import { cwdArgs, logLevelArgs } from '../_shared' import prepareCommand from '../prepare' import { selectModulesAutocomplete } from './_autocomplete' -import { basePackageName, checkNuxtCompatibility, ensureNuxtDependency, fetchModules, forwardCommandArgs, getProjectDependencies, isPnpmWorkspace, MODULES_API_URL, parseModuleSpec, resolveModuleEntry } from './_utils' +import { basePackageName, checkNuxtCompatibility, ensureNuxtDependency, fetchModules, forwardCommandArgs, getProjectDependencies, MODULES_API_URL, parseModuleSpec, resolveModuleEntry } from './_utils' const WHITESPACE_RE = /\s/ -/** Read order for the `--packageManager` hint. Names nypm adds later are listed after these. */ -const PACKAGE_MANAGER_ORDER = ['npm', 'pnpm', 'yarn', 'bun', 'deno'] - -const packageManagerNames = packageManagers - .map(pm => pm.name) - .sort((a, b) => rank(a) - rank(b)) - -function rank(name: string): number { - const index = PACKAGE_MANAGER_ORDER.indexOf(name) - return index === -1 ? PACKAGE_MANAGER_ORDER.length : index -} - interface ResolvedModule { nuxtModule?: NuxtModule pkg: string @@ -204,7 +192,7 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip const a = notInstalledModules.length > 1 ? '' : ' a' logger.info(`Installing ${notInstalledModulesList} as${a}${isDev ? ' development' : ''} ${dependency}`) - const packageManager = await resolvePackageManager(cwd, packageManagerName) + const packageManager = await selectPackageManager(cwd, packageManagerName) const peers = resolveRequiredPeerDependencies(notInstalledModules, dependencies) if (peers.length > 0) { @@ -225,7 +213,6 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip packageManager, dependencies: [...notInstalledModules.map(module => module.pkg), ...peers], dev: isDev, - workspace: isPnpmWorkspace(packageManager, cwd), onOutput: installLog.onOutput, onStatus: message => installSpinner.message(message), signal: installController.signal, @@ -289,23 +276,21 @@ async function addModules(modules: ResolvedModule[], { skipInstall = false, skip * (e.g. the one selected during `nuxt init`) and otherwise detecting one from * the project. */ -async function resolvePackageManager(cwd: string, name?: string): Promise { - const requested = name ? packageManagers.find(pm => pm.name === name) : undefined +async function selectPackageManager(cwd: string, name?: string): Promise { + const requested = isPackageManagerName(name) ? name : undefined if (name && !requested) { logger.warn(`Unknown package manager ${styleText('cyan', name)}, detecting one instead.`) } - const detected = await detectProjectPackageManager(cwd) + const detected = await detectPackageManager(cwd) if (!requested) { - return detected ?? resolvePackageManagerDescriptor('npm') + return detected ?? { name: 'npm', agent: 'npm' } } - // The detected descriptor knows the version the project pins, which the static - // list does not, so prefer it when it agrees with the requested manager. - return detected?.name === requested.name + return detected?.name === requested ? detected - : resolvePackageManagerDescriptor(requested.name) + : { name: requested, agent: requested } } /** diff --git a/packages/nuxt-cli/src/commands/module/remove.ts b/packages/nuxt-cli/src/commands/module/remove.ts index d0bec0e70..7b7041482 100644 --- a/packages/nuxt-cli/src/commands/module/remove.ts +++ b/packages/nuxt-cli/src/commands/module/remove.ts @@ -8,20 +8,21 @@ import process from 'node:process' import { styleText } from 'node:util' import { cancel, confirm, isCancel, multiselect } from '@clack/prompts' import { defineCommand } from 'citty' -import { detectPackageManager, removeDependency } from 'nypm' import { resolve } from 'pathe' import { readPackageJSON } from 'pkg-types' import { runCommandDef as runCommand } from '../../run-command' import { readNuxtConfig, removeNuxtConfigEntries } from '../../utils/config' import { CONFIG_KEYS } from '../../utils/config-parse' +import { createInstallLog, runInstall } from '../../utils/install' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' import { readDependencyPackageJson } from '../../utils/package-json' +import { detectPackageManager } from '../../utils/package-managers' import { relativeToProcess } from '../../utils/paths' import { cwdArgs, logLevelArgs } from '../_shared' import prepareCommand from '../prepare' -import { basePackageName, ensureNuxtDependency, fetchModules, forwardCommandArgs, getProjectDependencies, isPnpmWorkspace, MODULES_API_URL } from './_utils' +import { basePackageName, ensureNuxtDependency, fetchModules, forwardCommandArgs, getProjectDependencies, MODULES_API_URL } from './_utils' interface OrphanedPeer { peer: string @@ -216,18 +217,20 @@ async function removeModules(modules: string[], { skipInstall = false, skipConfi const dependency = toRemove.length > 1 ? 'dependencies' : 'dependency' logger.info(`Uninstalling ${removeList} ${dependency}`) - const packageManager = await detectPackageManager(cwd) + const packageManager = await detectPackageManager(cwd) ?? { name: 'npm', agent: 'npm' } + const installLog = createInstallLog() - const removed = await removeDependency(toRemove, { + const result = await runInstall({ cwd, packageManager, - workspace: isPnpmWorkspace(packageManager, cwd), - }).then(() => true).catch((error) => { - logger.error(String(error)) - return false + dependencies: toRemove, + uninstall: true, + onOutput: installLog.onOutput, }) + installLog.finish(result) - if (!removed) { + if (!result.success) { + logger.error(result.error ?? `Failed to uninstall ${removeList}`) return false } } diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index dbd44ec30..0fb30f48f 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -1,3 +1,4 @@ +import type { DetectResult } from 'package-manager-detector' import type { TSConfig } from 'pkg-types' import { existsSync, readFileSync } from 'node:fs' import { writeFile } from 'node:fs/promises' @@ -7,15 +8,16 @@ import { styleText } from 'node:util' import { cancel, confirm, isCancel, select } from '@clack/prompts' import { defineCommand } from 'citty' import { resolveModulePath } from 'exsolve' -import { addDevDependency, detectPackageManager } from 'nypm' import { dirname, resolve } from 'pathe' import { readPackageJSON, readTSConfig } from 'pkg-types' import { hasTTY } from 'std-env' import { x } from 'tinyexec' import { resolveDotenvFileNames } from '../utils/args' +import { runInstall } from '../utils/install' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' +import { detectPackageManager } from '../utils/package-managers' import { resolveRootDir } from '../utils/paths' import { withNodePath } from '../utils/resolve-nuxt' import { createSpinner } from '../utils/spinner' @@ -258,13 +260,12 @@ async function ensureGolarConfig(cwd: string) { } async function promptTypeCheckerInstall(cwd: string, preferred?: TypeChecker): Promise { - const packageManager = await detectPackageManager(cwd, { includeParentDirs: true }) - const pmName = packageManager?.name ?? 'npm' + const packageManager = await detectPackageManager(cwd) ?? { name: 'npm', agent: 'npm' } + const pmName = packageManager.name const devFlag = pmName === 'bun' ? '-d' : '-D' - const pmCommand = packageManager?.command ?? pmName if (!hasTTY) { - printInstallInstructions(pmCommand, devFlag, preferred ? [preferred] : CHECKER_PRIORITY) + printInstallInstructions(pmName, devFlag, preferred ? [preferred] : CHECKER_PRIORITY) return } @@ -289,7 +290,7 @@ async function promptTypeCheckerInstall(cwd: string, preferred?: TypeChecker): P selected = answer } - const installCommand = formatInstallCommand(selected, pmCommand, devFlag) + const installCommand = formatInstallCommand(selected, pmName, devFlag) const { missing } = TYPE_CHECKERS[selected].resolve(cwd) if (missing.length > 0) { @@ -336,7 +337,7 @@ function formatInstallCommand(checker: TypeChecker, pmCommand: string, devFlag: async function installMissingPackages(options: { cwd: string - packageManager: Awaited> + packageManager: DetectResult pmName: string packages: string[] installCommand: string @@ -358,17 +359,15 @@ async function installMissingPackages(options: { const spin = createSpinner() spin.start(`Installing ${list} with ${styleText('cyan', pmName)}`) - try { - await addDevDependency(packages, { cwd, packageManager, silent: true }) + const result = await runInstall({ cwd, packageManager, dependencies: packages, dev: true }) + if (result.success) { spin.stop(`Installed ${list}`) return true } - catch (error) { - spin.error(`Failed to install ${list}`) - logger.error(error instanceof Error ? error.message : String(error)) - logger.info(`You can install ${plural ? 'them' : 'it'} manually with:\n\n ${styleText('bold', installCommand)}\n`) - return false - } + spin.error(`Failed to install ${list}`) + logger.error(result.error ?? result.output) + logger.info(`You can install ${plural ? 'them' : 'it'} manually with:\n\n ${styleText('bold', installCommand)}\n`) + return false } async function writeTypes(cwd: string, dotenv?: string[], logLevel?: 'silent' | 'info' | 'verbose', overrides?: Record) { diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index cd5e31768..fa18f51f2 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -9,7 +9,6 @@ import { styleText } from 'node:util' import { cancel, isCancel, note, select } from '@clack/prompts' import { defineCommand } from 'citty' -import { detectPackageManager } from 'nypm' import { dirname, relative, resolve } from 'pathe' import { findWorkspaceDir, readPackageJSON } from 'pkg-types' import { resolveCatalogEntry, updateCatalogEntries } from '../utils/catalog' @@ -18,7 +17,7 @@ import { createInstallLog, runDedupe, runInstall, takeUnreportedIgnoredBuilds } import { loadKit } from '../utils/kit' import { intro, logger, outro } from '../utils/logger' import { cleanupNuxtDirs, nuxtVersionToGitIdentifier } from '../utils/nuxt' -import { getPackageManagerVersion } from '../utils/packageManagers' +import { detectPackageManager, getLockFiles, getPackageManagerVersion } from '../utils/package-managers' import { relativeToProcess, resolveRootDir } from '../utils/paths' import { createSpinner } from '../utils/spinner' import { getNuxtVersion, resolveRegistryVersion } from '../utils/versions' @@ -182,7 +181,7 @@ export default defineCommand({ logger.info(`Please either add the ${styleText('cyan', 'packageManager')} field to ${styleText('cyan', 'package.json')} or execute the installation command for your package manager. For example, you can use ${styleText('cyan', 'pnpm i')}, ${styleText('cyan', 'npm i')}, ${styleText('cyan', 'bun i')}, or ${styleText('cyan', 'yarn i')}, and then try again.`) process.exit(1) } - const { name: packageManagerName, lockFile: lockFileCandidates } = packageManager + const { name: packageManagerName } = packageManager const packageManagerVersion = getPackageManagerVersion(packageManagerName) logger.step(`Package manager: ${styleText('cyan', packageManagerName)} ${packageManagerVersion}`) @@ -216,7 +215,7 @@ export default defineCommand({ const toRemove = ['node_modules'] - const lockFile = findLockFile(cwd, workspaceDir, lockFileCandidates) + const lockFile = findLockFile(cwd, workspaceDir, getLockFiles(packageManagerName)) if (lockFile) { toRemove.push(lockFile) } @@ -327,7 +326,6 @@ export default defineCommand({ packageManager, dependencies: directPackages, dev: nuxtDependencyType === 'devDependencies', - workspace: packageManager.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')), ...hooks, }), ) diff --git a/packages/nuxt-cli/src/dev/preflight.ts b/packages/nuxt-cli/src/dev/preflight.ts index b13383205..2a269b9b5 100644 --- a/packages/nuxt-cli/src/dev/preflight.ts +++ b/packages/nuxt-cli/src/dev/preflight.ts @@ -1,4 +1,4 @@ -import type { PackageManager } from 'nypm' +import type { DetectResult } from 'package-manager-detector' import { accessSync, constants, existsSync, readdirSync, readFileSync } from 'node:fs' import { styleText } from 'node:util' @@ -222,15 +222,9 @@ async function checkDependencies(cwd: string, interactive: boolean): Promise offerInstall(cwd, interactive)) } -/** - * The package manager to tell the user to run. `../utils/install` is loaded on - * demand: it and `nypm` are only needed once something is already wrong, so they - * stay out of the modules a successful `nuxt dev` loads. - */ -async function detectInstaller(cwd: string): Promise { - const { detectProjectPackageManager, resolvePackageManagerDescriptor } = await import('../utils/install') - - return await detectProjectPackageManager(cwd) ?? resolvePackageManagerDescriptor('npm') +async function detectInstaller(cwd: string): Promise { + const { detectPackageManager } = await import('../utils/package-managers') + return await detectPackageManager(cwd) ?? { name: 'npm', agent: 'npm' } } async function offerInstall(cwd: string, interactive: boolean): Promise { diff --git a/packages/nuxt-cli/src/utils/headless.ts b/packages/nuxt-cli/src/utils/headless.ts index ecb281ea3..f830baf4b 100644 --- a/packages/nuxt-cli/src/utils/headless.ts +++ b/packages/nuxt-cli/src/utils/headless.ts @@ -1,4 +1,4 @@ -import type { PackageManagerName } from 'nypm' +import type { AgentName } from 'package-manager-detector' import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' @@ -18,7 +18,7 @@ const CREATE_BIN_RE = /^create-nuxt(?:-app)?$/ // `@latest` everywhere: package managers happily reuse a cached `create-nuxt`, // so an unpinned invocation can keep scaffolding from a stale version. -const createCommands: Partial> = { +const createCommands: Partial> = { npm: 'npm create nuxt@latest', pnpm: 'pnpm create nuxt@latest', yarn: 'yarn create nuxt@latest', @@ -26,9 +26,9 @@ const createCommands: Partial> = { deno: 'deno run -A npm:create-nuxt@latest', } -function currentPackageManager(userAgent: string | undefined): PackageManagerName | undefined { +function currentPackageManager(userAgent: string | undefined): AgentName | undefined { const name = userAgent?.split('/')[0] - return name && name in createCommands ? name as PackageManagerName : undefined + return name && name in createCommands ? name as AgentName : undefined } /** @@ -146,7 +146,7 @@ export interface HeadlessCommandOptions { prefix?: string dir: string template: string - packageManager: PackageManagerName + packageManager: AgentName gitInit: boolean install: boolean force?: boolean diff --git a/packages/nuxt-cli/src/utils/install.ts b/packages/nuxt-cli/src/utils/install.ts index b1c0827c5..1a0401db0 100644 --- a/packages/nuxt-cli/src/utils/install.ts +++ b/packages/nuxt-cli/src/utils/install.ts @@ -1,20 +1,18 @@ import type { Buffer } from 'node:buffer' import type { ChildProcess, SpawnOptions } from 'node:child_process' +import type { DetectResult } from 'package-manager-detector' -import type { PackageManager, PackageManagerName } from 'nypm' import { spawn } from 'node:child_process' -import { rmSync, statSync } from 'node:fs' +import { existsSync, rmSync, statSync } from 'node:fs' import { delimiter, resolve } from 'node:path' import process from 'node:process' - import { styleText } from 'node:util' + import { log, S_BAR } from '@clack/prompts' -import { addDependency, dedupeDependencies, detectPackageManager, installDependencies, packageManagers } from 'nypm' -import { provider } from 'std-env' -import { normalizeSpawnCommand, x } from 'tinyexec' +import { resolveCommand } from 'package-manager-detector/commands' +import { normalizeSpawnCommand } from 'tinyexec' -/** Package managers nypm delegates to corepack, so version pins keep working. */ -const COREPACK_PACKAGE_MANAGERS = new Set(['pnpm', 'yarn']) +import { getLockFiles } from './package-managers' const TRAILING_DOT_RE = /\.$/ const SURROUNDING_QUOTES_RE = /^"(.*)"$/ @@ -28,41 +26,28 @@ const IGNORED_BUILDS_RE = /Ignored build scripts:\s*([^\n│]+)/ // eslint-disable-next-line no-control-regex const ANSI_RE = /\u001B\[[\d;]*[A-Z]/gi -/** - * Build a full package manager descriptor for `name`, carrying over nypm's own - * metadata (lockfile and marker files, and the major version it derives command - * flags from) rather than only the command to run. - */ -export function resolvePackageManagerDescriptor(name: PackageManagerName, version?: string): PackageManager { - const descriptor = packageManagers.find(pm => pm.name === name) - const resolvedVersion = version ?? descriptor?.version +const DENO_SPECIFIER_RE = /^(?:npm|jsr|file):/ - return { - ...descriptor, - name, - command: name, - version: resolvedVersion, - majorVersion: resolvedVersion?.split('.')[0] ?? descriptor?.majorVersion, +function getInstallCommand({ cwd, packageManager: { agent, name }, dependencies = [], dev, uninstall }: InstallOptions) { + if (!dependencies.length) { + return resolveCommand(agent, 'install', [])! } -} - -/** - * Detect the package manager a project uses, looking at the project itself - * before its parent directories, so a project nested inside another workspace is - * not installed with that workspace's package manager. - */ -export async function detectProjectPackageManager(cwd: string): Promise { - return await detectPackageManager(cwd, { includeParentDirs: false }) - ?? await detectPackageManager(cwd) + return resolveCommand(agent, uninstall ? 'uninstall' : 'add', [ + // pnpm refuses to add to a workspace root without this flag. + ...name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')) ? ['--workspace-root'] : [], + ...dev && !uninstall ? ['-D'] : [], + ...name === 'deno' ? dependencies.map(dep => DENO_SPECIFIER_RE.test(dep) ? dep : `npm:${dep}`) : dependencies, + ])! } export interface InstallOptions { cwd: string - packageManager: PackageManager + packageManager: DetectResult /** Packages to add. When omitted, the project's existing dependencies are installed. */ dependencies?: string[] dev?: boolean - workspace?: boolean | string + /** Remove `dependencies` instead of adding them. */ + uninstall?: boolean /** Called with each line of package manager output as it arrives. */ onOutput?: (line: string) => void /** Called with a status update while the install is running. */ @@ -81,8 +66,6 @@ export interface InstallResult { ignoredBuilds: string[] /** Human-readable failure reason. Only set when `success` is `false`. */ error?: string - /** Set when the package manager binary could not be found. */ - missingPackageManager?: boolean } /** @@ -95,29 +78,11 @@ export interface InstallResult { * instead of hanging behind a spinner where its question is invisible. */ export async function runInstall(options: InstallOptions): Promise { - const nypmOptions = { - cwd: options.cwd, - packageManager: options.packageManager, - dev: options.dev, - workspace: options.workspace, - dry: true, - } - - const { exec } = options.dependencies?.length - ? await addDependency(options.dependencies, nypmOptions) - : await installDependencies(nypmOptions) - - if (!exec) { - return { success: true, output: '', command: '', ignoredBuilds: [] } - } - - const args = [...exec.args, ...nonInteractiveArgs(options.packageManager)] - const [command, commandArgs] = await withCorepack(exec.command, args) - - return await execute(command, commandArgs, options) + const { command, args } = getInstallCommand(options) + return await execute(command, [...args, ...nonInteractiveArgs(options.packageManager)], options) } -export interface DedupeOptions extends Omit { +export interface DedupeOptions extends Omit { /** Delete node_modules and the lockfile, then resolve dependencies from scratch. */ recreateLockfile?: boolean /** Lockfile path relative to cwd. */ @@ -133,40 +98,21 @@ export async function runDedupe(options: DedupeOptions): Promise rmSync(resolve(options.cwd, 'node_modules'), { recursive: true, force: true }) const lockFiles = options.lockFile ? [options.lockFile] - : [options.packageManager.lockFile].flat().filter(Boolean) as string[] + : getLockFiles(options.packageManager.name) for (const lockFile of lockFiles) { rmSync(resolve(options.cwd, lockFile), { force: true }) } return await runInstall(options) } - let exec - try { - ({ exec } = await dedupeDependencies({ - cwd: options.cwd, - packageManager: options.packageManager, - recreateLockfile: false, - dry: true, - })) - } - catch (error) { - return { - success: false, - output: '', - command: '', - ignoredBuilds: [], - error: error instanceof Error ? error.message : String(error), - } - } - + const { agent, name } = options.packageManager + // Yarn 1 deduplicates as part of a regular install. + const exec = resolveCommand(agent, agent === 'yarn' ? 'install' : 'dedupe', []) if (!exec) { - return { success: true, output: '', command: '', ignoredBuilds: [] } + return { success: false, output: '', command: '', ignoredBuilds: [], error: `Deduplication is not supported for ${name}` } } - const args = [...exec.args, ...nonInteractiveArgs(options.packageManager)] - const [command, commandArgs] = await withCorepack(exec.command, args) - - return await execute(command, commandArgs, options) + return await execute(exec.command, [...exec.args, ...nonInteractiveArgs(options.packageManager)], options) } /** @@ -178,7 +124,7 @@ export async function runDedupe(options: DedupeOptions): Promise * frozen lockfile), which would break installs for templates whose lockfile is * not perfectly in sync. */ -export function nonInteractiveArgs(packageManager: PackageManager): string[] { +export function nonInteractiveArgs(packageManager: DetectResult): string[] { if (packageManager.name === 'pnpm') { // `confirm-modules-purge` prompts before recreating `node_modules`, and // `strict-dep-builds` turns blocked dependency build scripts into a failed @@ -294,7 +240,6 @@ function execute(command: string, args: string[], options: InstallOptions): Prom if (!isExecutableAvailable(command, options.cwd)) { return Promise.resolve({ success: false, - missingPackageManager: true, error: `\`${command}\` was not found. Install it (or choose a different package manager) and try again.`, command: displayCommand, output: '', @@ -305,8 +250,8 @@ function execute(command: string, args: string[], options: InstallOptions): Prom const spawnOptions: SpawnOptions = { cwd: options.cwd, stdio: ['ignore', 'pipe', 'pipe'], - // Give the install its own process group so aborting can reach the package - // manager corepack spawns, not just corepack itself. + // Give the install its own process group so aborting also reaches the + // processes the package manager spawns. detached: process.platform !== 'win32', } const normalized = normalizeSpawnCommand(command, args, spawnOptions) @@ -381,7 +326,6 @@ function execute(command: string, args: string[], options: InstallOptions): Prom if (error.code === 'ENOENT') { finish({ success: false, - missingPackageManager: true, error: `\`${command}\` was not found. Install it (or choose a different package manager) and try again.`, }) return @@ -432,22 +376,3 @@ function tail(output: string): string { const lines = output.replace(/\s+$/, '').split('\n') return lines.slice(-OUTPUT_TAIL_LINES).join('\n') } - -let corepackAvailable: Promise | undefined - -async function withCorepack(command: string, args: string[]): Promise<[string, string[]]> { - if (!COREPACK_PACKAGE_MANAGERS.has(command) || !await hasCorepack()) { - return [command, args] - } - return ['corepack', [command, ...args]] -} - -async function hasCorepack(): Promise { - if (provider === 'stackblitz' || !isExecutableAvailable('corepack')) { - return false - } - corepackAvailable ||= Promise.resolve(x('corepack', ['--version'])) - .then(result => result.exitCode === 0) - .catch(() => false) - return await corepackAvailable -} diff --git a/packages/nuxt-cli/src/utils/package-managers.ts b/packages/nuxt-cli/src/utils/package-managers.ts new file mode 100644 index 000000000..6c6dccc39 --- /dev/null +++ b/packages/nuxt-cli/src/utils/package-managers.ts @@ -0,0 +1,34 @@ +import type { AgentName, DetectResult } from 'package-manager-detector' +import { execFileSync } from 'node:child_process' +import process from 'node:process' + +import { LOCKS } from 'package-manager-detector/constants' +import { detect } from 'package-manager-detector/detect' + +/** Supported package managers, in the order they are offered to the user. */ +export const packageManagerNames: AgentName[] = ['npm', 'pnpm', 'yarn', 'bun', 'deno', 'aube', 'nub'] + +export function isPackageManagerName(name: unknown): name is AgentName { + return packageManagerNames.includes(name as AgentName) +} + +/** Lockfiles written by `name`, excluding workspace manifests. */ +export function getLockFiles(name: AgentName): string[] { + return Object.keys(LOCKS).filter(file => LOCKS[file] === name && !file.includes('workspace')) +} + +/** Detect the nearest project's package manager, checking only `cwd` when `includeParentDirs` is `false`. */ +export async function detectPackageManager(cwd: string, { includeParentDirs = true } = {}): Promise { + return await detect({ cwd, stopDir: includeParentDirs ? undefined : cwd }).catch(() => null) ?? undefined +} + +export function getPackageManagerVersion(command: string) { + // Package managers are `.cmd` shims on Windows, which cannot be spawned without a shell. + const isWindows = process.platform === 'win32' + try { + return execFileSync(isWindows ? `"${command}"` : command, ['--version'], { shell: isWindows, stdio: ['ignore', 'pipe', 'ignore'] }).toString('utf8').trim() + } + catch { + return 'unknown' + } +} diff --git a/packages/nuxt-cli/src/utils/packageManagers.ts b/packages/nuxt-cli/src/utils/packageManagers.ts deleted file mode 100644 index 38d7c0a99..000000000 --- a/packages/nuxt-cli/src/utils/packageManagers.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { execFileSync } from 'node:child_process' -import process from 'node:process' - -export function getPackageManagerVersion(command: string) { - // Package managers are `.cmd` shims on Windows, which cannot be spawned without a shell. - const isWindows = process.platform === 'win32' - try { - return execFileSync(isWindows ? `"${command}"` : command, ['--version'], { shell: isWindows, stdio: ['ignore', 'pipe', 'ignore'] }).toString('utf8').trim() - } - catch { - return 'unknown' - } -} diff --git a/packages/nuxt-cli/test/unit/commands/add.spec.ts b/packages/nuxt-cli/test/unit/commands/add.spec.ts index 966bf7861..e93f5da91 100644 --- a/packages/nuxt-cli/test/unit/commands/add.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/add.spec.ts @@ -11,7 +11,7 @@ const { readNuxtConfig, addNuxtConfigEntries, detectPackageManager, mock$fetch } return { readNuxtConfig: vi.fn(() => Promise.resolve({ file: '/fake-dir/nuxt.config.ts', cwd: '/fake-dir', modules: [], extends: [] })), addNuxtConfigEntries: vi.fn(() => Promise.resolve()), - detectPackageManager: vi.fn(() => Promise.resolve({ name: 'npm', command: 'npm' })), + detectPackageManager: vi.fn(() => Promise.resolve({ name: 'npm', agent: 'npm' })), mock$fetch: vi.fn(), } }) @@ -24,12 +24,10 @@ vi.mock('../../../src/utils/config', async () => { } }) -vi.mock('nypm', async () => { - return { - detectPackageManager, - packageManagers: [{ name: 'npm', command: 'npm' }], - } -}) +vi.mock('../../../src/utils/package-managers', async importOriginal => ({ + ...await importOriginal(), + detectPackageManager, +})) vi.mock('pkg-types', async () => { return { @@ -162,8 +160,7 @@ describe('nuxt add command', () => { cwd: '/fake-dir', dependencies: ['@nuxt/ui@3.0.0'], dev: true, - packageManager: { name: 'npm', command: 'npm' }, - workspace: false, + packageManager: { name: 'npm', agent: 'npm' }, }), ) @@ -185,8 +182,7 @@ describe('nuxt add command', () => { cwd: '/fake-dir', dependencies: ['@nuxt/icon@1.0.0'], dev: true, - packageManager: { name: 'npm', command: 'npm' }, - workspace: false, + packageManager: { name: 'npm', agent: 'npm' }, }), ) }) @@ -207,8 +203,7 @@ describe('nuxt add command', () => { cwd: '/fake-dir', dependencies: ['@nuxt/ui@3.0.0', '@nuxt/icon@1.0.0'], dev: true, - packageManager: { name: 'npm', command: 'npm' }, - workspace: false, + packageManager: { name: 'npm', agent: 'npm' }, }), ) }) @@ -344,7 +339,7 @@ describe('nuxt add command', () => { }) it('should not update nuxt.config when the install fails', async () => { - runInstall.mockResolvedValue({ success: false, output: '', command: 'npm install @nuxt/ui@3.0.0', error: '`npm` was not found.', missingPackageManager: true, ignoredBuilds: [] }) + runInstall.mockResolvedValue({ success: false, output: '', command: 'npm install @nuxt/ui@3.0.0', error: '`npm` was not found.', ignoredBuilds: [] }) const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) const addCommand = await (commands as CommandsType).subCommands.add() @@ -357,7 +352,7 @@ describe('nuxt add command', () => { }) expect(runInstall).toHaveBeenCalledWith( - expect.objectContaining({ packageManager: { name: 'npm', command: 'npm' } }), + expect.objectContaining({ packageManager: { name: 'npm', agent: 'npm' } }), ) expect(addNuxtConfigEntries).not.toHaveBeenCalled() expect(exit).toHaveBeenCalledWith(1) diff --git a/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts b/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts index c320cb16f..e96088d0d 100644 --- a/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/add-config.spec.ts @@ -16,9 +16,9 @@ const { readNuxtConfig, addNuxtConfigEntries } = vi.hoisted(() => ({ let manifest: Record = {} vi.mock('../../../../src/utils/config', () => ({ readNuxtConfig, addNuxtConfigEntries, createNuxtConfig: vi.fn() })) -vi.mock('nypm', () => ({ - detectPackageManager: () => Promise.resolve({ name: 'npm', command: 'npm' }), - packageManagers: [{ name: 'npm', command: 'npm' }], +vi.mock('../../../../src/utils/package-managers', async importOriginal => ({ + ...await importOriginal(), + detectPackageManager: () => Promise.resolve({ name: 'npm', agent: 'npm' }), })) vi.mock('pkg-types', () => ({ readPackageJSON: () => Promise.resolve({ devDependencies: { nuxt: '3.0.0' } }), diff --git a/packages/nuxt-cli/test/unit/commands/module/add.spec.ts b/packages/nuxt-cli/test/unit/commands/module/add.spec.ts index e231ebeb7..febc8e44f 100644 --- a/packages/nuxt-cli/test/unit/commands/module/add.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/add.spec.ts @@ -9,7 +9,7 @@ import * as versions from '../../../../src/utils/versions' const { readNuxtConfig, addNuxtConfigEntries, detectPackageManager } = vi.hoisted(() => ({ readNuxtConfig: vi.fn(() => Promise.resolve({ file: '/fake-dir/nuxt.config.ts', cwd: '/fake-dir', modules: [], extends: [] })), addNuxtConfigEntries: vi.fn(() => Promise.resolve()), - detectPackageManager: vi.fn(() => Promise.resolve({ name: 'npm', command: 'npm' })), + detectPackageManager: vi.fn(() => Promise.resolve({ name: 'npm', agent: 'npm' })), })) let v3 = '3.0.0' @@ -27,12 +27,10 @@ function applyMocks() { createNuxtConfig: vi.fn(), } }) - vi.mock('nypm', async () => { - return { - detectPackageManager, - packageManagers: [{ name: 'npm', command: 'npm' }], - } - }) + vi.mock('../../../../src/utils/package-managers', async importOriginal => ({ + ...await importOriginal(), + detectPackageManager, + })) vi.mock('pkg-types', async () => { return { readPackageJSON: () => { @@ -130,8 +128,7 @@ describe('module add', () => { cwd: '/fake-dir', dependencies: [`@nuxt/content@${v3}`], dev: true, - packageManager: { name: 'npm', command: 'npm' }, - workspace: false, + packageManager: { name: 'npm', agent: 'npm' }, })) }) @@ -148,8 +145,7 @@ describe('module add', () => { cwd: '/fake-dir', dependencies: ['@nuxt/content@2.9.0'], dev: true, - packageManager: { name: 'npm', command: 'npm' }, - workspace: false, + packageManager: { name: 'npm', agent: 'npm' }, })) }) @@ -166,8 +162,7 @@ describe('module add', () => { cwd: '/fake-dir', dependencies: ['@nuxt/content@2.13.1'], dev: true, - packageManager: { name: 'npm', command: 'npm' }, - workspace: false, + packageManager: { name: 'npm', agent: 'npm' }, })) }) @@ -184,8 +179,7 @@ describe('module add', () => { cwd: '/fake-dir', dependencies: ['@nuxt/content@3.1.1'], dev: true, - packageManager: { name: 'npm', command: 'npm' }, - workspace: false, + packageManager: { name: 'npm', agent: 'npm' }, })) }) }) diff --git a/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts b/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts index f5fa5314b..055ba4bb4 100644 --- a/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/module/remove.spec.ts @@ -8,8 +8,8 @@ interface FakeConfig { file: string, cwd: string, modules: string[], extends: st const readNuxtConfig = vi.fn((): Promise => Promise.resolve({ file: '/fake-dir/nuxt.config.ts', cwd: '/fake-dir', modules: ['@nuxt/content'], extends: [] })) const removeNuxtConfigEntries = vi.fn(() => Promise.resolve()) -const removeDependency = vi.fn(() => Promise.resolve()) -const detectPackageManager = vi.fn(() => Promise.resolve({ name: 'npm' })) +const runInstall = vi.fn((_options: Record) => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] })) +const detectPackageManager = vi.fn(() => Promise.resolve({ name: 'npm', agent: 'npm' })) const confirm = vi.fn((): Promise => Promise.resolve(false)) const multiselect = vi.fn((): Promise => Promise.resolve([])) @@ -28,7 +28,14 @@ interface CommandsType { } vi.mock('../../../../src/utils/config', () => ({ readNuxtConfig, removeNuxtConfigEntries })) -vi.mock('nypm', () => ({ removeDependency, detectPackageManager })) +vi.mock('../../../../src/utils/package-managers', async importOriginal => ({ + ...await importOriginal(), + detectPackageManager, +})) +vi.mock('../../../../src/utils/install', async importOriginal => ({ + ...await importOriginal(), + runInstall, +})) vi.mock('pkg-types', () => ({ readPackageJSON })) vi.mock('../../../../src/utils/package-json', () => ({ readDependencyPackageJson })) vi.mock('@clack/prompts', async importOriginal => ({ @@ -68,7 +75,7 @@ describe('module remove', () => { beforeEach(() => { readNuxtConfig.mockClear() removeNuxtConfigEntries.mockClear() - removeDependency.mockClear() + runInstall.mockClear() confirm.mockReset().mockResolvedValue(false) multiselect.mockReset().mockResolvedValue([]) readPackageJSON.mockReset().mockImplementation(() => Promise.resolve(defaultProjectPkg)) @@ -84,11 +91,12 @@ describe('module remove', () => { }, }) - expect(removeDependency).toHaveBeenCalledWith(['@nuxt/content'], { + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ + dependencies: ['@nuxt/content'], + uninstall: true, cwd: '/fake-dir', - packageManager: { name: 'npm' }, - workspace: false, - }) + packageManager: { name: 'npm', agent: 'npm' }, + })) }) it('should strip a database module subpath before uninstalling', async () => { @@ -115,7 +123,7 @@ describe('module remove', () => { const removeCommand = await (commands as CommandsType).subCommands.remove() await removeCommand.setup({ args: { cwd: '/fake-dir', skipConfig: true, moduleName: ['example-module'] } }) - expect(removeDependency).toHaveBeenCalledWith(['example'], expect.objectContaining({ cwd: '/fake-dir' })) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: ['example'], uninstall: true, cwd: '/fake-dir' })) }) it('should remove a Nuxt module by npm name', async () => { @@ -127,11 +135,12 @@ describe('module remove', () => { }, }) - expect(removeDependency).toHaveBeenCalledWith(['@nuxt/content'], { + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ + dependencies: ['@nuxt/content'], + uninstall: true, cwd: '/fake-dir', - packageManager: { name: 'npm' }, - workspace: false, - }) + packageManager: { name: 'npm', agent: 'npm' }, + })) }) it('should remove modules selected from the picker when none are given', async () => { @@ -146,7 +155,7 @@ describe('module remove', () => { }) expect(multiselect).toHaveBeenCalled() - expect(removeDependency).toHaveBeenCalledWith(['@nuxt/content'], expect.objectContaining({ cwd: '/fake-dir' })) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: ['@nuxt/content'], uninstall: true, cwd: '/fake-dir' })) }) it('should remove a layer from `extends` without uninstalling a local path', async () => { @@ -162,7 +171,7 @@ describe('module remove', () => { }) expect(removeNuxtConfigEntries).toHaveBeenCalledWith(expect.anything(), { extends: ['./layers/admin'] }) - expect(removeDependency).not.toHaveBeenCalled() + expect(runInstall).not.toHaveBeenCalled() }) it('should skip uninstall when --skipInstall is set', async () => { @@ -175,7 +184,7 @@ describe('module remove', () => { }, }) - expect(removeDependency).not.toHaveBeenCalled() + expect(runInstall).not.toHaveBeenCalled() }) it('should stop before uninstall when the config update fails', async () => { @@ -189,7 +198,7 @@ describe('module remove', () => { }, })).rejects.toThrow('process.exit unexpectedly called with "1"') - expect(removeDependency).not.toHaveBeenCalled() + expect(runInstall).not.toHaveBeenCalled() }) it('should skip config update when --skipConfig is set', async () => { @@ -219,7 +228,7 @@ describe('module remove', () => { }, }) - expect(removeDependency).not.toHaveBeenCalled() + expect(runInstall).not.toHaveBeenCalled() }) it('should remove orphaned peer dependencies when confirmed', async () => { @@ -244,10 +253,7 @@ describe('module remove', () => { }) expect(confirm).toHaveBeenCalled() - expect(removeDependency).toHaveBeenCalledWith( - ['@vee-validate/nuxt', 'vee-validate'], - expect.objectContaining({ cwd: '/fake-dir' }), - ) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: ['@vee-validate/nuxt', 'vee-validate'], uninstall: true, cwd: '/fake-dir' })) }) it('should not suggest removing optional peer dependencies', async () => { @@ -268,7 +274,7 @@ describe('module remove', () => { await removeCommand.setup({ args: { cwd: '/fake-dir', moduleName: ['@example/nuxt'] } }) expect(confirm).not.toHaveBeenCalled() - expect(removeDependency).toHaveBeenCalledWith(['@example/nuxt'], expect.objectContaining({ cwd: '/fake-dir' })) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: ['@example/nuxt'], uninstall: true, cwd: '/fake-dir' })) }) it('should keep orphaned peer dependencies when declined', async () => { @@ -293,10 +299,7 @@ describe('module remove', () => { }) expect(confirm).toHaveBeenCalled() - expect(removeDependency).toHaveBeenCalledWith( - ['@vee-validate/nuxt'], - expect.objectContaining({ cwd: '/fake-dir' }), - ) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: ['@vee-validate/nuxt'], uninstall: true, cwd: '/fake-dir' })) }) it('should inspect retained dependencies in parallel', async () => { @@ -356,9 +359,6 @@ describe('module remove', () => { }) expect(confirm).not.toHaveBeenCalled() - expect(removeDependency).toHaveBeenCalledWith( - ['@vee-validate/nuxt'], - expect.objectContaining({ cwd: '/fake-dir' }), - ) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ dependencies: ['@vee-validate/nuxt'], uninstall: true, cwd: '/fake-dir' })) }) }) diff --git a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts index cebd92c38..40110d20c 100644 --- a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts @@ -5,8 +5,8 @@ import process from 'node:process' import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { addDevDependency, answers, resolveModulePath, tinyexec, writeTypes } = vi.hoisted(() => ({ - addDevDependency: vi.fn(() => Promise.resolve()), +const { runInstall, answers, resolveModulePath, tinyexec, writeTypes } = vi.hoisted(() => ({ + runInstall: vi.fn((_options: Record) => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] })), answers: { select: [] as unknown[], confirm: [] as unknown[] }, resolveModulePath: vi.fn(), tinyexec: vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })), @@ -20,10 +20,14 @@ vi.mock('exsolve', async importOriginal => ({ resolveModulePath, })) -vi.mock('nypm', async importOriginal => ({ - ...await importOriginal(), - addDevDependency, - detectPackageManager: () => Promise.resolve({ name: 'pnpm', command: 'pnpm' }), +vi.mock('../../../src/utils/install', async importOriginal => ({ + ...await importOriginal(), + runInstall, +})) + +vi.mock('../../../src/utils/package-managers', async importOriginal => ({ + ...await importOriginal(), + detectPackageManager: () => Promise.resolve({ name: 'pnpm', agent: 'pnpm' }), })) vi.mock('../../../src/utils/kit', () => ({ @@ -83,7 +87,7 @@ beforeEach(async () => { answers.confirm.length = 0 vi.clearAllMocks() tinyexec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) - addDevDependency.mockImplementation(() => Promise.resolve()) + runInstall.mockImplementation(() => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] })) installed('typescript', 'vue-tsc/bin/vue-tsc.js') await writeFile(join(cwd, 'package.json'), JSON.stringify({ name: 'app', type: 'module' })) }) @@ -138,14 +142,14 @@ describe('typecheck installation advice', () => { installed() answers.select.push('vue-tsc') answers.confirm.push(true) - addDevDependency.mockImplementation(() => { + runInstall.mockImplementation(() => { installed('typescript', 'vue-tsc/bin/vue-tsc.js') - return Promise.resolve() + return Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] }) }) await runTypecheck() - expect(addDevDependency).toHaveBeenCalledWith(['typescript', 'vue-tsc'], expect.objectContaining({ cwd })) + expect(runInstall).toHaveBeenCalledWith(expect.objectContaining({ cwd, dependencies: ['typescript', 'vue-tsc'], dev: true })) expect(tinyexec).toHaveBeenCalledTimes(1) }) diff --git a/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts b/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts index c0f649b1a..b4e662acc 100644 --- a/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/upgrade-run.spec.ts @@ -27,8 +27,8 @@ vi.mock('@clack/prompts', async (importOriginal) => { } }) -vi.mock('nypm', async importOriginal => ({ - ...await importOriginal(), +vi.mock('../../../src/utils/package-managers', async importOriginal => ({ + ...await importOriginal(), detectPackageManager, })) @@ -96,7 +96,7 @@ beforeEach(async () => { answers.select.length = 0 progress.length = 0 vi.clearAllMocks() - detectPackageManager.mockResolvedValue({ name: 'pnpm', command: 'pnpm', lockFile: ['pnpm-lock.yaml'] }) + detectPackageManager.mockResolvedValue({ name: 'pnpm', agent: 'pnpm' }) getNuxtVersion.mockResolvedValue('4.0.0') resolveRegistryVersion.mockResolvedValue('4.1.0') runInstall.mockResolvedValue(installed) @@ -211,7 +211,7 @@ describe('upgrade with a pnpm catalog', () => { }) it('should leave the catalog alone for a package manager without catalogs', async () => { - detectPackageManager.mockResolvedValue({ name: 'npm', command: 'npm', lockFile: ['package-lock.json'] }) + detectPackageManager.mockResolvedValue({ name: 'npm', agent: 'npm' }) await writeProject({ 'package-lock.json': '{}' }) await runUpgrade() diff --git a/packages/nuxt-cli/test/unit/utils/install.spec.ts b/packages/nuxt-cli/test/unit/utils/install.spec.ts index 1776146d2..b7b2057cc 100644 --- a/packages/nuxt-cli/test/unit/utils/install.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/install.spec.ts @@ -4,29 +4,35 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import process from 'node:process' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getIgnoredBuilds, isExecutableAvailable, nonInteractiveArgs, runDedupe, runInstall, takeUnreportedIgnoredBuilds } from '../../../src/utils/install' -async function createFakePackageManager(script: string[] = ['#!/bin/sh', 'echo "all done"']) { +async function createFakePackageManager(name = 'pnpm', script: string[] = ['#!/bin/sh', 'echo "all done"']) { const dir = await mkdtemp(join(tmpdir(), 'nuxt-install-test-')) - const command = join(dir, 'fake-package-manager') - await writeFile(command, script.join('\n')) - await chmod(command, 0o755) - return { dir, command } + const bin = join(dir, 'bin') + await mkdir(bin) + await writeFile(join(bin, name), script.join('\n')) + await chmod(join(bin, name), 0o755) + vi.stubEnv('PATH', bin) + return { dir } } +afterEach(() => { + vi.unstubAllEnvs() +}) + describe('nonInteractiveArgs', () => { it('should opt pnpm out of prompts and strict dep builds', () => { - expect(nonInteractiveArgs({ name: 'pnpm', command: 'pnpm' })).toEqual([ + expect(nonInteractiveArgs({ name: 'pnpm', agent: 'pnpm' })).toEqual([ '--config.confirm-modules-purge=false', '--config.strict-dep-builds=false', ]) }) it('should pass no extra arguments to other package managers', () => { - expect(nonInteractiveArgs({ name: 'npm', command: 'npm' })).toEqual([]) - expect(nonInteractiveArgs({ name: 'yarn', command: 'yarn' })).toEqual([]) + expect(nonInteractiveArgs({ name: 'npm', agent: 'npm' })).toEqual([]) + expect(nonInteractiveArgs({ name: 'yarn', agent: 'yarn' })).toEqual([]) }) }) @@ -90,81 +96,77 @@ describe('isExecutableAvailable', () => { describe('runInstall', () => { it.skipIf(process.platform === 'win32')('should report ignored builds printed before the end of the output', async () => { - const dir = await mkdtemp(join(tmpdir(), 'nuxt-install-test-')) - const command = join(dir, 'fake-package-manager') - await writeFile(command, [ + const { dir } = await createFakePackageManager('pnpm', [ '#!/bin/sh', 'echo "Ignored build scripts: esbuild@0.28.1."', 'i=0; while [ $i -lt 60 ]; do echo "line $i"; i=$((i+1)); done', - ].join('\n')) - await chmod(command, 0o755) + ]) - const result = await runInstall({ cwd: dir, packageManager: { name: 'pnpm', command } }) + const result = await runInstall({ cwd: dir, packageManager: { name: 'pnpm', agent: 'pnpm' } }) expect(result.success).toBe(true) expect(result.output).not.toContain('Ignored build scripts') expect(result.ignoredBuilds).toEqual(['esbuild@0.28.1']) }) - it.skipIf(process.platform === 'win32')('should run the package manager directly when corepack is not on the PATH', async () => { - const { dir } = await createFakePackageManager() - const bin = join(dir, 'bin') - const nodeBin = join(dir, 'node', 'bin') - await mkdir(bin) - await mkdir(nodeBin, { recursive: true }) - await writeFile(join(bin, 'pnpm'), ['#!/bin/sh', 'echo "all done"'].join('\n')) - await writeFile(join(nodeBin, 'corepack'), ['#!/bin/sh', 'echo "0.34.0"'].join('\n')) - await chmod(join(bin, 'pnpm'), 0o755) - await chmod(join(nodeBin, 'corepack'), 0o755) - vi.stubEnv('PATH', bin) - const execPath = process.execPath - process.execPath = join(nodeBin, 'node') - vi.resetModules() - - try { - const { runInstall } = await import('../../../src/utils/install') - const result = await runInstall({ cwd: dir, packageManager: { name: 'pnpm', command: 'pnpm' } }) - - expect(result.success).toBe(true) - expect(result.command).toMatch(/^pnpm install/) - } - finally { - vi.unstubAllEnvs() - process.execPath = execPath - } - }) - it('should report a missing package manager instead of throwing', async () => { + vi.stubEnv('PATH', join(tmpdir(), 'nuxt-cli-nonexistent-bin')) const result = await runInstall({ - cwd: process.cwd(), - packageManager: { name: 'npm', command: 'nuxt-cli-nonexistent-package-manager' }, + cwd: tmpdir(), + packageManager: { name: 'npm', agent: 'npm' }, }) expect(result.success).toBe(false) - expect(result.missingPackageManager).toBe(true) - expect(result.error).toContain('nuxt-cli-nonexistent-package-manager') - expect(result.command).toBe('nuxt-cli-nonexistent-package-manager install') + expect(result.error).toContain('`npm` was not found') + expect(result.command).toBe('npm i') + }) +}) + +describe.skipIf(process.platform === 'win32')('runInstall arguments', () => { + it('should add dev dependencies with `-D`', async () => { + const { dir } = await createFakePackageManager('npm') + const result = await runInstall({ cwd: dir, packageManager: { name: 'npm', agent: 'npm' }, dependencies: ['a'], dev: true }) + expect(result.command).toBe('npm i -D a') + }) + + it('should add to the root of a pnpm workspace', async () => { + const { dir } = await createFakePackageManager('pnpm') + await writeFile(join(dir, 'pnpm-workspace.yaml'), '') + const result = await runInstall({ cwd: dir, packageManager: { name: 'pnpm', agent: 'pnpm' }, dependencies: ['a'] }) + expect(result.command).toMatch(/^pnpm add --workspace-root a /) + }) + + it('should prefix bare deno specifiers with `npm:`', async () => { + const { dir } = await createFakePackageManager('deno') + const result = await runInstall({ cwd: dir, packageManager: { name: 'deno', agent: 'deno' }, dependencies: ['a', 'jsr:@std/path'] }) + expect(result.command).toBe('deno add npm:a jsr:@std/path') }) }) describe('runDedupe', () => { it.skipIf(process.platform === 'win32')('should dedupe without printing the package manager output', async () => { - const { dir, command } = await createFakePackageManager() + const { dir } = await createFakePackageManager() const lines: string[] = [] const result = await runDedupe({ cwd: dir, - packageManager: { name: 'pnpm', command }, + packageManager: { name: 'pnpm', agent: 'pnpm' }, onOutput: line => lines.push(line), }) expect(result.success).toBe(true) - expect(result.command).toBe(`${command} dedupe --config.confirm-modules-purge=false --config.strict-dep-builds=false`) + expect(result.command).toBe(`pnpm dedupe --config.confirm-modules-purge=false --config.strict-dep-builds=false`) expect(lines).toEqual(['all done']) }) + it.skipIf(process.platform === 'win32')('should dedupe with Yarn 1 by installing', async () => { + const { dir } = await createFakePackageManager('yarn') + const result = await runDedupe({ cwd: dir, packageManager: { name: 'yarn', agent: 'yarn' } }) + expect(result.command).toBe('yarn install') + }) + it.skipIf(process.platform === 'win32')('should install after removing node_modules and the selected lockfile', async () => { - const { dir, command } = await createFakePackageManager() + const { dir } = await createFakePackageManager() const appDir = join(dir, 'app') const nodeModules = join(appDir, 'node_modules') const localLockFile = join(appDir, 'pnpm-lock.yaml') @@ -175,13 +177,13 @@ describe('runDedupe', () => { const result = await runDedupe({ cwd: appDir, - packageManager: { name: 'pnpm', command, lockFile: 'pnpm-lock.yaml' }, + packageManager: { name: 'pnpm', agent: 'pnpm' }, recreateLockfile: true, lockFile: '../pnpm-lock.yaml', }) expect(result.success).toBe(true) - expect(result.command).toContain(`${command} install`) + expect(result.command).toContain('pnpm i ') expect(existsSync(nodeModules)).toBe(false) expect(existsSync(workspaceLockFile)).toBe(false) expect(existsSync(localLockFile)).toBe(true) @@ -190,7 +192,7 @@ describe('runDedupe', () => { it('should report unsupported dedupe commands as failures', async () => { const result = await runDedupe({ cwd: process.cwd(), - packageManager: { name: 'bun', command: 'bun' }, + packageManager: { name: 'bun', agent: 'bun' }, }) expect(result.success).toBe(false) @@ -198,12 +200,13 @@ describe('runDedupe', () => { }) it('should report a missing package manager instead of throwing', async () => { + vi.stubEnv('PATH', join(tmpdir(), 'nuxt-cli-nonexistent-bin')) const result = await runDedupe({ - cwd: process.cwd(), - packageManager: { name: 'npm', command: 'nuxt-cli-nonexistent-package-manager' }, + cwd: tmpdir(), + packageManager: { name: 'npm', agent: 'npm' }, }) expect(result.success).toBe(false) - expect(result.missingPackageManager).toBe(true) + expect(result.error).toContain('`npm` was not found') }) }) diff --git a/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts b/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts new file mode 100644 index 000000000..5050d8740 --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/package-managers.spec.ts @@ -0,0 +1,48 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import process from 'node:process' + +import { describe, expect, it } from 'vitest' + +import { detectPackageManager, getLockFiles, getPackageManagerVersion } from '../../../src/utils/package-managers' + +describe('detectPackageManager', () => { + async function createNestedProject() { + const root = await mkdtemp(join(tmpdir(), 'nuxt-pm-test-')) + const app = join(root, 'app') + await mkdir(app) + await writeFile(join(root, 'pnpm-lock.yaml'), '') + return { root, app } + } + + it('should prefer the nearest project over a parent workspace', async () => { + const { app } = await createNestedProject() + await writeFile(join(app, 'package.json'), JSON.stringify({ packageManager: 'yarn@1.22.0' })) + + expect((await detectPackageManager(app))?.name).toBe('yarn') + }) + + it('should not look above `cwd` when `includeParentDirs` is false', async () => { + const { app } = await createNestedProject() + + expect((await detectPackageManager(app))?.name).toBe('pnpm') + expect(await detectPackageManager(app, { includeParentDirs: false })).toBeUndefined() + }) +}) + +describe('getLockFiles', () => { + it('should not include workspace manifests', () => { + expect(getLockFiles('pnpm')).toEqual(['pnpm-lock.yaml']) + }) +}) + +describe('getPackageManagerVersion', () => { + it('returns the command version', () => { + expect(getPackageManagerVersion(process.execPath)).toBe(process.version) + }) + + it('does not fail when the package manager is unavailable', () => { + expect(getPackageManagerVersion('nuxt-cli-missing-package-manager')).toBe('unknown') + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/packageManagers.spec.ts b/packages/nuxt-cli/test/unit/utils/packageManagers.spec.ts deleted file mode 100644 index 66e673bea..000000000 --- a/packages/nuxt-cli/test/unit/utils/packageManagers.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import process from 'node:process' - -import { describe, expect, it } from 'vitest' - -import { getPackageManagerVersion } from '../../../src/utils/packageManagers' - -describe('getPackageManagerVersion', () => { - it('returns the command version', () => { - expect(getPackageManagerVersion(process.execPath)).toBe(process.version) - }) - - it('does not fail when the package manager is unavailable', () => { - expect(getPackageManagerVersion('nuxt-cli-missing-package-manager')).toBe('unknown') - }) -}) diff --git a/packages/nuxt-cli/tsdown.config.ts b/packages/nuxt-cli/tsdown.config.ts index 883f9bb50..1b04d77c5 100644 --- a/packages/nuxt-cli/tsdown.config.ts +++ b/packages/nuxt-cli/tsdown.config.ts @@ -11,6 +11,6 @@ export const packaging: PackagingContract = { export default defineCliConfig({ entry: ['src/index.ts', 'src/boot.ts', 'src/dev/index.ts'], - deps: { onlyBundle: ['@bomb.sh/tab', 'citty', 'h3', 'nypm', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES }, + deps: { onlyBundle: ['@bomb.sh/tab', 'citty', 'h3', '@speed-highlight/core'], neverBundle: PARSER_PACKAGES }, ...packaging, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c91062e4..80dbd8bf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,9 +327,9 @@ importers: giget: specifier: ^3.3.1 version: 3.3.1 - nypm: - specifier: ^0.6.10 - version: 0.6.10 + package-manager-detector: + specifier: ^1.8.0 + version: 1.8.0 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -424,6 +424,9 @@ importers: oxc-parser: specifier: '>=0.56.3' version: 0.150.0 + package-manager-detector: + specifier: ^1.8.0 + version: 1.8.0 pathe: specifier: ^2.0.3 version: 2.0.3 @@ -497,9 +500,6 @@ importers: nitropack: specifier: 2.13.4 version: 2.13.4(oxc-parser@0.150.0)(rolldown@1.2.11)(srvx@1.0.5)(supports-color@10.2.2)(vite@8.3.0) - nypm: - specifier: ^0.6.10 - version: 0.6.10 rolldown: specifier: 1.2.11 version: 1.2.11 From 6bf0128bd433cdf9608c2019a016513a8f262f9f Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 29 Sep 2026 13:06:00 +0200 Subject: [PATCH 2/2] perf: lazy load detector --- packages/nuxt-cli/src/commands/module/add.ts | 4 ++-- packages/nuxt-cli/src/commands/module/remove.ts | 4 ++-- packages/nuxt-cli/src/commands/typecheck.ts | 6 +++--- packages/nuxt-cli/src/dev/preflight.ts | 4 ++-- packages/nuxt-cli/src/utils/package-managers.ts | 3 +++ 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/nuxt-cli/src/commands/module/add.ts b/packages/nuxt-cli/src/commands/module/add.ts index b13b4c65e..4db939e90 100644 --- a/packages/nuxt-cli/src/commands/module/add.ts +++ b/packages/nuxt-cli/src/commands/module/add.ts @@ -19,7 +19,7 @@ import { fetchJson } from '../../utils/fetch' import { createInstallLog, runInstall, takeUnreportedIgnoredBuilds } from '../../utils/install' import { logger } from '../../utils/logger' import { logNetworkError } from '../../utils/network' -import { detectPackageManager, isPackageManagerName, packageManagerNames } from '../../utils/package-managers' +import { defaultPackageManager, detectPackageManager, isPackageManagerName, packageManagerNames } from '../../utils/package-managers' import { detectNpmRegistry } from '../../utils/registry' import { createSpinner } from '../../utils/spinner' import { getNuxtVersion } from '../../utils/versions' @@ -285,7 +285,7 @@ async function selectPackageManager(cwd: string, name?: string): Promise 1 ? 'dependencies' : 'dependency' logger.info(`Uninstalling ${removeList} ${dependency}`) - const packageManager = await detectPackageManager(cwd) ?? { name: 'npm', agent: 'npm' } + const packageManager = await detectPackageManager(cwd) ?? defaultPackageManager const installLog = createInstallLog() const result = await runInstall({ diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 0fb30f48f..358b732e2 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -14,10 +14,8 @@ import { hasTTY } from 'std-env' import { x } from 'tinyexec' import { resolveDotenvFileNames } from '../utils/args' -import { runInstall } from '../utils/install' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' -import { detectPackageManager } from '../utils/package-managers' import { resolveRootDir } from '../utils/paths' import { withNodePath } from '../utils/resolve-nuxt' import { createSpinner } from '../utils/spinner' @@ -260,7 +258,8 @@ async function ensureGolarConfig(cwd: string) { } async function promptTypeCheckerInstall(cwd: string, preferred?: TypeChecker): Promise { - const packageManager = await detectPackageManager(cwd) ?? { name: 'npm', agent: 'npm' } + const { defaultPackageManager, detectPackageManager } = await import('../utils/package-managers') + const packageManager = await detectPackageManager(cwd) ?? defaultPackageManager const pmName = packageManager.name const devFlag = pmName === 'bun' ? '-d' : '-D' @@ -359,6 +358,7 @@ async function installMissingPackages(options: { const spin = createSpinner() spin.start(`Installing ${list} with ${styleText('cyan', pmName)}`) + const { runInstall } = await import('../utils/install') const result = await runInstall({ cwd, packageManager, dependencies: packages, dev: true }) if (result.success) { spin.stop(`Installed ${list}`) diff --git a/packages/nuxt-cli/src/dev/preflight.ts b/packages/nuxt-cli/src/dev/preflight.ts index 2a269b9b5..e52cba04e 100644 --- a/packages/nuxt-cli/src/dev/preflight.ts +++ b/packages/nuxt-cli/src/dev/preflight.ts @@ -223,8 +223,8 @@ async function checkDependencies(cwd: string, interactive: boolean): Promise { - const { detectPackageManager } = await import('../utils/package-managers') - return await detectPackageManager(cwd) ?? { name: 'npm', agent: 'npm' } + const { defaultPackageManager, detectPackageManager } = await import('../utils/package-managers') + return await detectPackageManager(cwd) ?? defaultPackageManager } async function offerInstall(cwd: string, interactive: boolean): Promise { diff --git a/packages/nuxt-cli/src/utils/package-managers.ts b/packages/nuxt-cli/src/utils/package-managers.ts index 6c6dccc39..6f979e7ba 100644 --- a/packages/nuxt-cli/src/utils/package-managers.ts +++ b/packages/nuxt-cli/src/utils/package-managers.ts @@ -8,6 +8,9 @@ import { detect } from 'package-manager-detector/detect' /** Supported package managers, in the order they are offered to the user. */ export const packageManagerNames: AgentName[] = ['npm', 'pnpm', 'yarn', 'bun', 'deno', 'aube', 'nub'] +/** Used when no package manager can be detected. */ +export const defaultPackageManager: DetectResult = { name: 'npm', agent: 'npm' } + export function isPackageManagerName(name: unknown): name is AgentName { return packageManagerNames.includes(name as AgentName) }