From e44e97e112462a820414b2b4f091f1a225a31da8 Mon Sep 17 00:00:00 2001 From: Benjamin Canac Date: Wed, 3 Jun 2026 12:57:55 +0200 Subject: [PATCH] fix(init): align template package manager with user selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates may ship with a hardcoded `packageManager` field (e.g. `pnpm@x`), a lockfile and marker files (`pnpm-workspace.yaml`) that don't match the package manager the user picks during `nuxi init`. Detect the template's package manager and, on mismatch, drop its lockfile, marker files and the `packageManager` field so the install generates a fresh, correct lockfile. Co-Authored-By: Sébastien Chopin --- packages/nuxi/src/commands/init.ts | 60 +++++++++++++++- packages/nuxi/test/unit/commands/init.spec.ts | 68 +++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/nuxi/test/unit/commands/init.spec.ts diff --git a/packages/nuxi/src/commands/init.ts b/packages/nuxi/src/commands/init.ts index ecae0e43..0d1965bb 100644 --- a/packages/nuxi/src/commands/init.ts +++ b/packages/nuxi/src/commands/init.ts @@ -3,13 +3,14 @@ import type { PackageManagerName } from 'nypm' import type { TemplateData } from '../utils/starter-templates' import { existsSync } from 'node:fs' +import { rm } from 'node:fs/promises' import process from 'node:process' import { box, cancel, confirm, intro, isCancel, outro, select, spinner, tasks, text } from '@clack/prompts' import { defineCommand } from 'citty' import { colors } from 'consola/utils' import { downloadTemplate, startShell } from 'giget' -import { installDependencies } from 'nypm' +import { detectPackageManager, installDependencies } from 'nypm' import { $fetch } from 'ofetch' import { basename, join, relative, resolve } from 'pathe' import { findFile, readPackageJSON, writePackageJSON } from 'pkg-types' @@ -347,6 +348,11 @@ export default defineCommand({ selectedPackageManager = result } + // Align the template with the selected package manager: remove foreign + // lockfiles and the hardcoded `packageManager` field (e.g. `pnpm@x`) that + // would otherwise mismatch the package manager the user just picked. + await alignPackageManager(template.dir, selectedPackageManager) + // Determine if we should init git let gitInit: boolean | undefined = ctx.args.gitInit === 'false' as unknown ? false : ctx.args.gitInit if (gitInit === undefined) { @@ -597,6 +603,58 @@ async function getTemplateDependencies(templateDir: string) { } } +export async function alignPackageManager(templateDir: string, packageManager: PackageManagerName) { + // Detect the package manager the template ships with (from its + // `packageManager` field, lockfile or marker files). Scope detection to the + // template directory so we don't pick up the parent project's setup. + const detected = await detectPackageManager(templateDir, { + includeParentDirs: false, + ignoreArgv: true, + }).catch(() => undefined) + + // Nothing to do if the template doesn't pin a package manager or it already + // matches the one the user picked (keep its lockfile to speed up install). + if (!detected || detected.name === packageManager) { + return + } + + // Drop the detected package manager's lockfile and marker files (e.g. + // `pnpm-workspace.yaml`) so the install generates a fresh, correct lockfile + // and nypm no longer mis-detects the package manager later. + const filesToRemove = [detected.lockFile, detected.files] + .flat() + .filter((file): file is string => Boolean(file)) + + await Promise.all( + filesToRemove.map(async (file) => { + const filePath = join(templateDir, file) + if (existsSync(filePath)) { + await rm(filePath, { force: true }).catch((err) => { + logger.warn(`Could not remove ${colors.cyan(file)}: ${err}`) + }) + } + }), + ) + + // Drop the hardcoded `packageManager` field: its baked-in version belongs to + // a different package manager, so it's no longer valid. + try { + const packageJsonPath = join(templateDir, 'package.json') + if (!existsSync(packageJsonPath)) { + return + } + + const pkg = await readPackageJSON(packageJsonPath, { try: true }) + if (pkg?.packageManager) { + delete pkg.packageManager + await writePackageJSON(packageJsonPath, pkg) + } + } + catch (err) { + logger.warn(`Could not update the \`packageManager\` field: ${err}`) + } +} + function detectCurrentPackageManager() { const userAgent = process.env.npm_config_user_agent if (!userAgent) { diff --git a/packages/nuxi/test/unit/commands/init.spec.ts b/packages/nuxi/test/unit/commands/init.spec.ts new file mode 100644 index 00000000..ff956ab9 --- /dev/null +++ b/packages/nuxi/test/unit/commands/init.spec.ts @@ -0,0 +1,68 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { alignPackageManager } from '../../../src/commands/init' + +describe('alignPackageManager', () => { + 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)) + } + + async function readPkg() { + return JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8')) + } + + it('removes the lockfile, marker files and `packageManager` field on mismatch', async () => { + await writePkg({ name: 'app', packageManager: 'pnpm@9.0.0' }) + await writeFile(join(dir, 'pnpm-lock.yaml'), '') + await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages: []') + + await alignPackageManager(dir, 'npm') + + expect(existsSync(join(dir, 'pnpm-lock.yaml'))).toBe(false) + expect(existsSync(join(dir, 'pnpm-workspace.yaml'))).toBe(false) + expect((await readPkg()).packageManager).toBeUndefined() + }) + + it('keeps everything when the template already matches the selection', async () => { + await writePkg({ name: 'app', packageManager: 'pnpm@9.0.0' }) + await writeFile(join(dir, 'pnpm-lock.yaml'), '') + await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages: []') + + await alignPackageManager(dir, 'pnpm') + + expect(existsSync(join(dir, 'pnpm-lock.yaml'))).toBe(true) + expect(existsSync(join(dir, 'pnpm-workspace.yaml'))).toBe(true) + expect((await readPkg()).packageManager).toBe('pnpm@9.0.0') + }) + + it('removes a lockfile even when the template has no `packageManager` field', async () => { + await writePkg({ name: 'app' }) + await writeFile(join(dir, 'pnpm-lock.yaml'), '') + + await alignPackageManager(dir, 'npm') + + expect(existsSync(join(dir, 'pnpm-lock.yaml'))).toBe(false) + expect((await readPkg()).packageManager).toBeUndefined() + }) + + it('is a no-op when the template pins no package manager', async () => { + await writePkg({ name: 'app' }) + + await expect(alignPackageManager(dir, 'npm')).resolves.not.toThrow() + expect((await readPkg()).name).toBe('app') + }) +})