From 85cb47c14ab90806d5e5507263ea62eca154c12a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 26 Jul 2026 12:12:56 +0000 Subject: [PATCH 1/5] feat: support pnpm catalogs --- packages/nuxt-cli/package.json | 1 + packages/nuxt-cli/src/commands/info.ts | 7 +- packages/nuxt-cli/src/commands/upgrade.ts | 90 +++++++++++++- packages/nuxt-cli/src/utils/catalog.ts | 115 ++++++++++++++++++ packages/nuxt-cli/src/utils/versions.ts | 30 ++++- .../test/unit/commands/upgrade.spec.ts | 30 ++++- .../nuxt-cli/test/unit/utils/catalog.spec.ts | 112 +++++++++++++++++ .../nuxt-cli/test/unit/utils/versions.spec.ts | 21 ++++ pnpm-lock.yaml | 3 + 9 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 packages/nuxt-cli/src/utils/catalog.ts create mode 100644 packages/nuxt-cli/test/unit/utils/catalog.spec.ts diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 831132a12..a8a2a4733 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -67,6 +67,7 @@ "perfect-debounce": "^2.1.0", "picocolors": "^1.1.1", "pkg-types": "^2.3.1", + "pnpm-workspace-yaml": "^1.6.1", "rc9": "^3.0.1", "scule": "^1.3.0", "source-map": "^0.7.6", diff --git a/packages/nuxt-cli/src/commands/info.ts b/packages/nuxt-cli/src/commands/info.ts index 70450e449..b8a62e564 100644 --- a/packages/nuxt-cli/src/commands/info.ts +++ b/packages/nuxt-cli/src/commands/info.ts @@ -15,6 +15,7 @@ import { writeText } from 'tinyclip' import { version as nuxiVersion } from '../../package.json' import { getBuilder } from '../utils/banner' +import { resolveCatalogEntry } from '../utils/catalog' import { formatInfoBox } from '../utils/formatting' import { tryResolveNuxt } from '../utils/kit' import { logger } from '../utils/logger' @@ -40,7 +41,8 @@ export default defineCommand({ const nuxtConfig = await getNuxtConfig(cwd) // Find nearest package.json - const { dependencies = {}, devDependencies = {} } = await readPackageJSON(cwd).catch(() => ({} as PackageJson)) + const projectPkg = await readPackageJSON(cwd).catch(() => ({} as PackageJson)) + const { dependencies = {}, devDependencies = {} } = projectPkg // Utils to query a dependency version const nuxtPath = tryResolveNuxt(cwd) @@ -54,7 +56,8 @@ export default defineCommand({ return pkg.version! } } - return dependencies[name] || devDependencies[name] + return resolveCatalogEntry(cwd, projectPkg, name)?.specifier + ?? (dependencies[name] || devDependencies[name]) } async function listModules(arr: NonNullable = []) { diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index adbca7e1e..bb9027248 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -12,13 +12,14 @@ import { dirname, relative, resolve } from 'pathe' import colors from 'picocolors' import { findWorkspaceDir, readPackageJSON } from 'pkg-types' +import { resolveCatalogEntry, setCatalogEntry } from '../utils/catalog' import { createInstallLog, runDedupe, runInstall, takeUnreportedIgnoredBuilds } from '../utils/install' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' import { cleanupNuxtDirs, nuxtVersionToGitIdentifier } from '../utils/nuxt' import { getPackageManagerVersion } from '../utils/packageManagers' import { relativeToProcess, resolveRootDir } from '../utils/paths' -import { getNuxtVersion } from '../utils/versions' +import { getNuxtVersion, resolveRegistryVersion } from '../utils/versions' import { logLevelArgs, rootDirArgs } from './_shared' function checkNuxtDependencyType(pkg: PackageJson): 'dependencies' | 'devDependencies' { @@ -31,6 +32,50 @@ function checkNuxtDependencyType(pkg: PackageJson): 'dependencies' | 'devDepende return 'dependencies' } +const ALIAS_SPEC_RE = /^npm:(.+)@([^@]+)$/ + +export interface InstallSpec { + /** Name the dependency is declared under. */ + name: string + /** Package the version is resolved from, which differs from `name` for an aliased install. */ + target: string + /** Dist-tag or semver range to resolve. */ + range: string + /** Whether `target` is reached through an `npm:` alias. */ + aliased: boolean +} + +/** + * Split an install argument such as `nuxt@latest` or + * `nuxt@npm:nuxt-nightly@latest` into the parts needed to resolve a concrete + * version for it. + */ +export function parseInstallSpec(spec: string): InstallSpec { + const separator = spec.lastIndexOf('@') + const name = spec.slice(0, separator) + const rest = spec.slice(separator + 1) + + const alias = spec.slice(spec.indexOf('@', 1) + 1).match(ALIAS_SPEC_RE) + if (alias) { + return { name: spec.slice(0, spec.indexOf('@', 1)), target: alias[1]!, range: alias[2]!, aliased: true } + } + + return { name, target: name, range: rest, aliased: false } +} + +/** + * The specifier a catalog entry should hold for `spec`. pnpm resolves dist-tags + * and ranges itself when it writes to `package.json`, but a catalog entry we + * write ourselves has to name a concrete version. + */ +export async function resolveCatalogSpecifier(spec: InstallSpec): Promise { + const version = await resolveRegistryVersion(spec.target, spec.range) + if (!version) { + return undefined + } + return spec.aliased ? `npm:${spec.target}@^${version}` : `^${version}` +} + const nuxtVersionTags = { '3.x': '3x', '4.x': 'latest', @@ -136,7 +181,23 @@ export default defineCommand({ const packagesToUpdate = pkg ? corePackages.filter(p => pkg.dependencies?.[p] || pkg.devDependencies?.[p]) : [] // Install latest version - const { npmPackages, nuxtVersion } = await getRequiredNewVersion(['nuxt', ...packagesToUpdate], ctx.args.channel) + const packageNames = ['nuxt', ...packagesToUpdate] + const { npmPackages, nuxtVersion } = await getRequiredNewVersion(packageNames, ctx.args.channel) + + // A `catalog:` dependency has to be upgraded in `pnpm-workspace.yaml`: asking + // pnpm to add a pinned version instead replaces the reference in + // `package.json`, silently taking the dependency out of the catalog. + const catalogUpdates: Array<{ catalog: string, spec: InstallSpec }> = [] + const directPackages: string[] = [] + for (const [index, name] of packageNames.entries()) { + const catalog = packageManagerName === 'pnpm' ? resolveCatalogEntry(cwd, pkg, name)?.catalog : undefined + if (catalog) { + catalogUpdates.push({ catalog, spec: parseInstallSpec(npmPackages[index]!) }) + } + else { + directPackages.push(npmPackages[index]!) + } + } // Force install const toRemove = ['node_modules'] @@ -192,6 +253,29 @@ export default defineCommand({ const verbose = ctx.args.logLevel === 'verbose' || Boolean(process.env.DEBUG) + if (catalogUpdates.length > 0) { + const catalogSpinner = spinner() + catalogSpinner.start('Updating catalog entries') + const updated: string[] = [] + const unresolved: string[] = [] + + for (const { catalog, spec } of catalogUpdates) { + const specifier = await resolveCatalogSpecifier(spec).catch(() => undefined) + if (!specifier) { + unresolved.push(spec.name) + continue + } + setCatalogEntry(cwd, catalog, spec.name, specifier) + updated.push(`${spec.name}@${specifier}`) + } + + catalogSpinner.stop(updated.length > 0 ? `Catalog entries updated: ${updated.join(', ')}` : 'No catalog entries updated') + + if (unresolved.length > 0) { + logger.warn(`Unable to resolve a ${versionType} version for ${unresolved.map(name => colors.cyan(name)).join(', ')}. Their catalog entries were left unchanged.`) + } + } + const installFailed = await withInstallSpinner( `Installing ${versionType} Nuxt ${nuxtVersion} release`, 'Nuxt packages installed', @@ -199,7 +283,7 @@ export default defineCommand({ hooks => runInstall({ cwd, packageManager, - dependencies: npmPackages, + dependencies: directPackages, dev: nuxtDependencyType === 'devDependencies', workspace: packageManager.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')), ...hooks, diff --git a/packages/nuxt-cli/src/utils/catalog.ts b/packages/nuxt-cli/src/utils/catalog.ts new file mode 100644 index 000000000..351c7d22f --- /dev/null +++ b/packages/nuxt-cli/src/utils/catalog.ts @@ -0,0 +1,115 @@ +import type { PackageJson } from 'pkg-types' + +import { existsSync, readFileSync, writeFileSync } from 'node:fs' + +import { dirname, join, resolve } from 'pathe' +import { parsePnpmWorkspaceYaml } from 'pnpm-workspace-yaml' + +const CATALOG_SPECIFIER_RE = /^catalog:(.*)$/ + +const DEFAULT_CATALOG = 'default' + +export interface CatalogEntry { + /** The catalog the specifier points at. `default` for a bare `catalog:`. */ + catalog: string + /** The specifier the catalog resolves to, e.g. `^4.2.0`. Absent when the catalog has no such entry. */ + specifier?: string +} + +export interface CatalogConfig { + /** Absolute path of the `pnpm-workspace.yaml` the catalogs are declared in. */ + filePath: string + catalogs: Record> +} + +/** + * The catalog a `catalog:` / `catalog:name` specifier refers to, or `undefined` + * for any other specifier. + */ +export function parseCatalogSpecifier(specifier: string | undefined): string | undefined { + const match = specifier?.match(CATALOG_SPECIFIER_RE) + if (!match) { + return undefined + } + return match[1] || DEFAULT_CATALOG +} + +/** Nearest `pnpm-workspace.yaml` at or above `cwd`. */ +export function findPnpmWorkspaceYaml(cwd: string): string | undefined { + for (let dir = resolve(cwd); ; dir = dirname(dir)) { + const filePath = join(dir, 'pnpm-workspace.yaml') + if (existsSync(filePath)) { + return filePath + } + if (dir === dirname(dir)) { + return undefined + } + } +} + +/** + * Catalogs declared in the nearest `pnpm-workspace.yaml`, keyed by catalog name. + * The top-level `catalog` key is exposed as {@link DEFAULT_CATALOG}. + */ +export function readCatalogConfig(cwd: string): CatalogConfig | undefined { + const filePath = findPnpmWorkspaceYaml(cwd) + if (!filePath) { + return undefined + } + + let workspace: ReturnType + try { + workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) + } + catch { + return undefined + } + + const json = workspace.toJSON() + const catalogs: CatalogConfig['catalogs'] = { ...json.catalogs } + if (json.catalog) { + catalogs[DEFAULT_CATALOG] = json.catalog + } + + if (Object.keys(catalogs).length === 0) { + return undefined + } + + return { filePath, catalogs } +} + +/** + * Resolve the specifier `pkg` is declared with in `pkgJson` through the project's + * catalogs. Returns `undefined` when the dependency is absent or not + * catalog-managed, so a caller can fall back to the declared specifier. + */ +export function resolveCatalogEntry(cwd: string, pkgJson: PackageJson | null | undefined, pkg: string): CatalogEntry | undefined { + const specifier = pkgJson?.dependencies?.[pkg] || pkgJson?.devDependencies?.[pkg] + const catalog = parseCatalogSpecifier(specifier) + if (!catalog) { + return undefined + } + + const config = readCatalogConfig(cwd) + return { catalog, specifier: config?.catalogs[catalog]?.[pkg] } +} + +/** + * Point a catalog entry at `specifier`, preserving the comments, anchors and + * aliases in `pnpm-workspace.yaml`. Returns whether the file was changed. + */ +export function setCatalogEntry(cwd: string, catalog: string, pkg: string, specifier: string): boolean { + const filePath = findPnpmWorkspaceYaml(cwd) + if (!filePath) { + return false + } + + const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) + workspace.setPackage(catalog, pkg, specifier) + if (!workspace.hasChanged()) { + return false + } + + writeFileSync(filePath, workspace.toString(), 'utf-8') + return true +} diff --git a/packages/nuxt-cli/src/utils/versions.ts b/packages/nuxt-cli/src/utils/versions.ts index fb1184466..20b9ac84f 100644 --- a/packages/nuxt-cli/src/utils/versions.ts +++ b/packages/nuxt-cli/src/utils/versions.ts @@ -1,9 +1,13 @@ import { readFileSync } from 'node:fs' import { resolveModulePath } from 'exsolve' +import { $fetch } from 'ofetch' import { readPackageJSON } from 'pkg-types' -import { coerce } from 'verkit' +import { joinURL } from 'ufo' +import { coerce, satisfies } from 'verkit' +import { resolveCatalogEntry } from './catalog' import { tryResolveNuxt } from './kit' +import { detectNpmRegistry } from './registry' /** * Names a resolved `nuxt` dependency can legitimately have, so that a @@ -21,10 +25,32 @@ export async function getNuxtVersion(cwd: string, cache = true) { return nuxtPkg.version } const pkg = await readPackageJSON(cwd) - const pkgDep = pkg?.dependencies?.nuxt || pkg?.devDependencies?.nuxt + const pkgDep = resolveCatalogEntry(cwd, pkg, 'nuxt')?.specifier + ?? (pkg?.dependencies?.nuxt || pkg?.devDependencies?.nuxt) return (pkgDep && coerce(pkgDep)) || DEFAULT_NUXT_VERSION } +/** + * The highest published version of `pkg` matching `range`, which may be a + * dist-tag (`latest`) or a semver range (`4`). + */ +export async function resolveRegistryVersion(pkg: string, range: string): Promise { + const scope = pkg.startsWith('@') ? pkg.split('/')[0]! : null + const { registry, authToken } = await detectNpmRegistry(scope) + + const packument = await $fetch<{ 'dist-tags'?: Record, 'versions'?: Record }>(joinURL(registry, pkg), { + headers: { + // The abbreviated packument is a fraction of the size of the full one and + // still carries every version and dist-tag. + Accept: 'application/vnd.npm.install-v1+json', + ...authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }, + }) + + return packument['dist-tags']?.[range] + ?? Object.keys(packument.versions ?? {}).findLast(version => satisfies(version, range)) +} + export function getPkgVersion(cwd: string, pkg: string, options?: { via?: string[] }) { const pkgJSON = getPkgJSON(cwd, pkg, options) return pkgJSON?.version ?? '' diff --git a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts index 56238002e..75297a86d 100644 --- a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { findLockFile } from '../../../src/commands/upgrade' +import { findLockFile, parseInstallSpec } from '../../../src/commands/upgrade' describe('findLockFile', () => { let tempDir: string @@ -60,3 +60,31 @@ describe('findLockFile', () => { expect(findLockFile(tempDir, tempDir, undefined)).toBeUndefined() }) }) + +describe('parseInstallSpec', () => { + it('should split a package from its dist-tag', () => { + expect(parseInstallSpec('nuxt@latest')).toEqual({ name: 'nuxt', target: 'nuxt', range: 'latest', aliased: false }) + }) + + it('should split a scoped package from its range', () => { + expect(parseInstallSpec('@nuxt/kit@3')).toEqual({ name: '@nuxt/kit', target: '@nuxt/kit', range: '3', aliased: false }) + }) + + it('should resolve an aliased nightly install through the aliased package', () => { + expect(parseInstallSpec('nuxt@npm:nuxt-nightly@latest')).toEqual({ + name: 'nuxt', + target: 'nuxt-nightly', + range: 'latest', + aliased: true, + }) + }) + + it('should resolve an aliased nightly install of a scoped package', () => { + expect(parseInstallSpec('@nuxt/kit@npm:@nuxt/kit-nightly@3x')).toEqual({ + name: '@nuxt/kit', + target: '@nuxt/kit-nightly', + range: '3x', + aliased: true, + }) + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts new file mode 100644 index 000000000..631da6fa1 --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts @@ -0,0 +1,112 @@ +import { mkdir, mkdtemp, 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 { findPnpmWorkspaceYaml, parseCatalogSpecifier, readCatalogConfig, resolveCatalogEntry } from '../../../src/utils/catalog' + +describe('parseCatalogSpecifier', () => { + it('should treat a bare `catalog:` as the default catalog', () => { + expect(parseCatalogSpecifier('catalog:')).toBe('default') + }) + + it('should read the name from a named catalog reference', () => { + expect(parseCatalogSpecifier('catalog:prod')).toBe('prod') + }) + + it('should ignore specifiers that are not catalog references', () => { + expect(parseCatalogSpecifier('^4.2.0')).toBeUndefined() + expect(parseCatalogSpecifier('workspace:*')).toBeUndefined() + expect(parseCatalogSpecifier('npm:nuxt-nightly@latest')).toBeUndefined() + expect(parseCatalogSpecifier(undefined)).toBeUndefined() + }) +}) + +describe('catalog resolution', () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'nuxt-catalog-test-')) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + it('should find the workspace file from a nested package', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.2.0\n') + const appDir = join(tempDir, 'packages', 'app') + await mkdir(appDir, { recursive: true }) + + expect(findPnpmWorkspaceYaml(appDir)).toBe(join(tempDir, 'pnpm-workspace.yaml')) + }) + + it('should expose the default and named catalogs together', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), [ + 'catalog:', + ' nuxt: ^4.2.0', + 'catalogs:', + ' dev:', + ' typescript: ^5.9.0', + ].join('\n')) + + expect(readCatalogConfig(tempDir)?.catalogs).toEqual({ + default: { nuxt: '^4.2.0' }, + dev: { typescript: '^5.9.0' }, + }) + }) + + it('should return no config when the workspace declares no catalogs', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n') + + expect(readCatalogConfig(tempDir)).toBeUndefined() + }) + + it('should resolve a dependency declared with a bare catalog reference', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.2.0\n') + + expect(resolveCatalogEntry(tempDir, { dependencies: { nuxt: 'catalog:' } }, 'nuxt')).toEqual({ + catalog: 'default', + specifier: '^4.2.0', + }) + }) + + it('should resolve a dev dependency declared with a named catalog reference', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalogs:\n prod:\n nuxt: 4.2.0\n') + + expect(resolveCatalogEntry(tempDir, { devDependencies: { nuxt: 'catalog:prod' } }, 'nuxt')).toEqual({ + catalog: 'prod', + specifier: '4.2.0', + }) + }) + + it('should resolve a specifier defined through a yaml anchor', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), [ + 'catalogs:', + ' prod:', + ' nuxt: &nuxt ^4.2.0', + ' legacy:', + ' nuxt: *nuxt', + ].join('\n')) + + expect(resolveCatalogEntry(tempDir, { dependencies: { nuxt: 'catalog:legacy' } }, 'nuxt')?.specifier).toBe('^4.2.0') + }) + + it('should report the catalog without a specifier when the entry is missing', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n vue: ^3.6.0\n') + + expect(resolveCatalogEntry(tempDir, { dependencies: { nuxt: 'catalog:' } }, 'nuxt')).toEqual({ + catalog: 'default', + specifier: undefined, + }) + }) + + it('should ignore dependencies that are not catalog-managed', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.2.0\n') + + expect(resolveCatalogEntry(tempDir, { dependencies: { nuxt: '^4.1.0' } }, 'nuxt')).toBeUndefined() + expect(resolveCatalogEntry(tempDir, {}, 'nuxt')).toBeUndefined() + expect(resolveCatalogEntry(tempDir, null, 'nuxt')).toBeUndefined() + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/versions.spec.ts b/packages/nuxt-cli/test/unit/utils/versions.spec.ts index 44d5926ad..fc52b13ec 100644 --- a/packages/nuxt-cli/test/unit/utils/versions.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/versions.spec.ts @@ -22,4 +22,25 @@ describe('getNuxtVersion', () => { expect(await getNuxtVersion(tempDir, false)).toBe('4.2.0') }) + + it('should resolve a catalog reference through pnpm-workspace.yaml', async () => { + await writeFile(join(tempDir, 'package.json'), JSON.stringify({ devDependencies: { nuxt: 'catalog:' } })) + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.2.0\n') + + expect(await getNuxtVersion(tempDir, false)).toBe('4.2.0') + }) + + it('should resolve a named catalog reference', async () => { + await writeFile(join(tempDir, 'package.json'), JSON.stringify({ dependencies: { nuxt: 'catalog:prod' } })) + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalogs:\n prod:\n nuxt: 4.1.2\n') + + expect(await getNuxtVersion(tempDir, false)).toBe('4.1.2') + }) + + it('should fall back when the catalog has no entry for nuxt', async () => { + await writeFile(join(tempDir, 'package.json'), JSON.stringify({ dependencies: { nuxt: 'catalog:' } })) + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n vue: ^3.6.0\n') + + expect(await getNuxtVersion(tempDir, false)).toBe('3.0.0') + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc05e9245..d2fe10fc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: pkg-types: specifier: ^2.3.1 version: 2.3.1 + pnpm-workspace-yaml: + specifier: ^1.6.1 + version: 1.6.1 rc9: specifier: ^3.0.1 version: 3.0.1 From 059bc38814a38b1a1b4fef7eb775ae85266a14e3 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 26 Jul 2026 15:13:02 +0100 Subject: [PATCH 2/5] test: use `pathe` --- packages/nuxt-cli/test/unit/utils/catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts index 631da6fa1..d623c17c1 100644 --- a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { findPnpmWorkspaceYaml, parseCatalogSpecifier, readCatalogConfig, resolveCatalogEntry } from '../../../src/utils/catalog' From e7013e536bf81c2d0df2486520bbf238127f4c50 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 26 Jul 2026 16:54:14 +0000 Subject: [PATCH 3/5] fix: report catalog write failures and pick highest matching version --- packages/nuxt-cli/src/commands/upgrade.ts | 14 +++++- packages/nuxt-cli/src/utils/catalog.ts | 28 +++++++---- packages/nuxt-cli/src/utils/versions.ts | 6 ++- .../test/unit/commands/upgrade.spec.ts | 31 +++++++++++- .../nuxt-cli/test/unit/utils/catalog.spec.ts | 48 ++++++++++++++++++- .../nuxt-cli/test/unit/utils/versions.spec.ts | 33 ++++++++++++- 6 files changed, 140 insertions(+), 20 deletions(-) diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index bb9027248..6b91dd274 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -258,6 +258,7 @@ export default defineCommand({ catalogSpinner.start('Updating catalog entries') const updated: string[] = [] const unresolved: string[] = [] + const failed: string[] = [] for (const { catalog, spec } of catalogUpdates) { const specifier = await resolveCatalogSpecifier(spec).catch(() => undefined) @@ -265,8 +266,13 @@ export default defineCommand({ unresolved.push(spec.name) continue } - setCatalogEntry(cwd, catalog, spec.name, specifier) - updated.push(`${spec.name}@${specifier}`) + const result = setCatalogEntry(cwd, catalog, spec.name, specifier) + if (result === 'failed') { + failed.push(spec.name) + } + else if (result === 'updated') { + updated.push(`${spec.name}@${specifier}`) + } } catalogSpinner.stop(updated.length > 0 ? `Catalog entries updated: ${updated.join(', ')}` : 'No catalog entries updated') @@ -274,6 +280,10 @@ export default defineCommand({ if (unresolved.length > 0) { logger.warn(`Unable to resolve a ${versionType} version for ${unresolved.map(name => colors.cyan(name)).join(', ')}. Their catalog entries were left unchanged.`) } + + if (failed.length > 0) { + logger.warn(`Unable to update the catalog entries for ${failed.map(name => colors.cyan(name)).join(', ')}. Check ${colors.cyan('pnpm-workspace.yaml')} is readable and valid.`) + } } const installFailed = await withInstallSpinner( diff --git a/packages/nuxt-cli/src/utils/catalog.ts b/packages/nuxt-cli/src/utils/catalog.ts index 351c7d22f..e707dc191 100644 --- a/packages/nuxt-cli/src/utils/catalog.ts +++ b/packages/nuxt-cli/src/utils/catalog.ts @@ -94,22 +94,30 @@ export function resolveCatalogEntry(cwd: string, pkgJson: PackageJson | null | u return { catalog, specifier: config?.catalogs[catalog]?.[pkg] } } +/** The outcome of a {@link setCatalogEntry} call. */ +export type SetCatalogEntryResult = 'updated' | 'unchanged' | 'failed' + /** * Point a catalog entry at `specifier`, preserving the comments, anchors and - * aliases in `pnpm-workspace.yaml`. Returns whether the file was changed. + * aliases in `pnpm-workspace.yaml`. */ -export function setCatalogEntry(cwd: string, catalog: string, pkg: string, specifier: string): boolean { +export function setCatalogEntry(cwd: string, catalog: string, pkg: string, specifier: string): SetCatalogEntryResult { const filePath = findPnpmWorkspaceYaml(cwd) if (!filePath) { - return false + return 'failed' } - const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) - workspace.setPackage(catalog, pkg, specifier) - if (!workspace.hasChanged()) { - return false - } + try { + const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) + workspace.setPackage(catalog, pkg, specifier) + if (!workspace.hasChanged()) { + return 'unchanged' + } - writeFileSync(filePath, workspace.toString(), 'utf-8') - return true + writeFileSync(filePath, workspace.toString(), 'utf-8') + return 'updated' + } + catch { + return 'failed' + } } diff --git a/packages/nuxt-cli/src/utils/versions.ts b/packages/nuxt-cli/src/utils/versions.ts index 20b9ac84f..4bcf3f8c1 100644 --- a/packages/nuxt-cli/src/utils/versions.ts +++ b/packages/nuxt-cli/src/utils/versions.ts @@ -3,7 +3,7 @@ import { resolveModulePath } from 'exsolve' import { $fetch } from 'ofetch' import { readPackageJSON } from 'pkg-types' import { joinURL } from 'ufo' -import { coerce, satisfies } from 'verkit' +import { coerce, findMaxSatisfying } from 'verkit' import { resolveCatalogEntry } from './catalog' import { tryResolveNuxt } from './kit' @@ -48,7 +48,9 @@ export async function resolveRegistryVersion(pkg: string, range: string): Promis }) return packument['dist-tags']?.[range] - ?? Object.keys(packument.versions ?? {}).findLast(version => satisfies(version, range)) + // the registry lists versions in publication order, so a backported patch can + // appear after a newer major and must not win + ?? findMaxSatisfying(Object.keys(packument.versions ?? {}), range) ?? undefined } export function getPkgVersion(cwd: string, pkg: string, options?: { via?: string[] }) { diff --git a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts index 75297a86d..ef2072e53 100644 --- a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts @@ -1,9 +1,15 @@ import { mkdir, mkdtemp, 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { findLockFile, parseInstallSpec } from '../../../src/commands/upgrade' +import { findLockFile, parseInstallSpec, resolveCatalogSpecifier } from '../../../src/commands/upgrade' +import { resolveRegistryVersion } from '../../../src/utils/versions' + +vi.mock('../../../src/utils/versions', async importOriginal => ({ + ...await importOriginal(), + resolveRegistryVersion: vi.fn(), +})) describe('findLockFile', () => { let tempDir: string @@ -88,3 +94,24 @@ describe('parseInstallSpec', () => { }) }) }) + +describe('resolveCatalogSpecifier', () => { + it('should write a caret range for a regular package', async () => { + vi.mocked(resolveRegistryVersion).mockResolvedValue('4.2.1') + + expect(await resolveCatalogSpecifier({ name: 'nuxt', target: 'nuxt', range: 'latest', aliased: false })).toBe('^4.2.1') + expect(vi.mocked(resolveRegistryVersion)).toHaveBeenCalledWith('nuxt', 'latest') + }) + + it('should write an npm alias for an aliased package', async () => { + vi.mocked(resolveRegistryVersion).mockResolvedValue('4.3.0-28991214-abcdef') + + expect(await resolveCatalogSpecifier({ name: 'nuxt', target: 'nuxt-nightly', range: 'latest', aliased: true })).toBe('npm:nuxt-nightly@^4.3.0-28991214-abcdef') + }) + + it('should return nothing when no version matches', async () => { + vi.mocked(resolveRegistryVersion).mockResolvedValue(undefined) + + expect(await resolveCatalogSpecifier({ name: 'nuxt', target: 'nuxt', range: '99', aliased: false })).toBeUndefined() + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts index d623c17c1..af60ba26f 100644 --- a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts @@ -1,10 +1,10 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { findPnpmWorkspaceYaml, parseCatalogSpecifier, readCatalogConfig, resolveCatalogEntry } from '../../../src/utils/catalog' +import { findPnpmWorkspaceYaml, parseCatalogSpecifier, readCatalogConfig, resolveCatalogEntry, setCatalogEntry } from '../../../src/utils/catalog' describe('parseCatalogSpecifier', () => { it('should treat a bare `catalog:` as the default catalog', () => { @@ -110,3 +110,47 @@ describe('catalog resolution', () => { expect(resolveCatalogEntry(tempDir, null, 'nuxt')).toBeUndefined() }) }) + +describe('setCatalogEntry', () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'nuxt-catalog-test-')) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + it('should update an entry while preserving comments', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, '# pinned by policy\ncatalog:\n nuxt: ^4.1.0\n vue: ^3.6.0\n') + + expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.2.0')).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('# pinned by policy\ncatalog:\n nuxt: ^4.2.0\n vue: ^3.6.0\n') + }) + + it('should update an entry in a named catalog', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'catalogs:\n prod:\n nuxt: ^4.1.0\n') + + expect(setCatalogEntry(tempDir, 'prod', 'nuxt', 'npm:nuxt-nightly@^4.3.0')).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('catalogs:\n prod:\n nuxt: npm:nuxt-nightly@^4.3.0\n') + }) + + it('should report an entry that already matches as unchanged', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.2.0\n') + + expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.2.0')).toBe('unchanged') + }) + + it('should fail when there is no workspace file', () => { + expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.2.0')).toBe('failed') + }) + + it('should fail when the workspace file cannot be parsed', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: "^4.2.0\n\tbad: [\n') + + expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.3.0')).toBe('failed') + }) +}) diff --git a/packages/nuxt-cli/test/unit/utils/versions.spec.ts b/packages/nuxt-cli/test/unit/utils/versions.spec.ts index fc52b13ec..39a6106d7 100644 --- a/packages/nuxt-cli/test/unit/utils/versions.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/versions.spec.ts @@ -2,9 +2,14 @@ import { mkdtemp, 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 { $fetch } from 'ofetch' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getNuxtVersion } from '../../../src/utils/versions' +import { detectNpmRegistry } from '../../../src/utils/registry' +import { getNuxtVersion, resolveRegistryVersion } from '../../../src/utils/versions' + +vi.mock('ofetch', () => ({ $fetch: vi.fn() })) +vi.mock('../../../src/utils/registry', () => ({ detectNpmRegistry: vi.fn() })) describe('getNuxtVersion', () => { let tempDir: string @@ -44,3 +49,27 @@ describe('getNuxtVersion', () => { expect(await getNuxtVersion(tempDir, false)).toBe('3.0.0') }) }) + +describe('resolveRegistryVersion', () => { + beforeEach(() => { + vi.mocked(detectNpmRegistry).mockResolvedValue({ registry: 'https://registry.example.com/', authToken: null }) + }) + + it('should prefer a matching dist-tag', async () => { + vi.mocked($fetch).mockResolvedValue({ 'dist-tags': { latest: '4.2.0' }, 'versions': { '4.2.0': {}, '5.0.0-rc.1': {} } }) + + expect(await resolveRegistryVersion('nuxt', 'latest')).toBe('4.2.0') + }) + + it('should pick the highest matching version regardless of publication order', async () => { + vi.mocked($fetch).mockResolvedValue({ 'dist-tags': { latest: '4.2.0' }, 'versions': { '3.17.0': {}, '3.19.0': {}, '3.18.1': {} } }) + + expect(await resolveRegistryVersion('nuxt', '3')).toBe('3.19.0') + }) + + it('should return nothing when no version matches', async () => { + vi.mocked($fetch).mockResolvedValue({ 'dist-tags': { latest: '4.2.0' }, 'versions': { '4.2.0': {} } }) + + expect(await resolveRegistryVersion('nuxt', '99')).toBeUndefined() + }) +}) From f902841842d5b318cc1a7c73d6ac7e300ec5383b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 26 Jul 2026 17:18:15 +0000 Subject: [PATCH 4/5] refactor: batch catalog writes, cache reads and bail on failed nuxt upgrade --- packages/nuxt-cli/src/commands/upgrade.ts | 48 ++++++++++++++----- packages/nuxt-cli/src/utils/catalog.ts | 41 +++++++++++++--- .../test/unit/commands/upgrade.spec.ts | 5 ++ .../nuxt-cli/test/unit/utils/catalog.spec.ts | 38 +++++++++++---- 4 files changed, 105 insertions(+), 27 deletions(-) diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index 6b91dd274..d03599bf2 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -1,5 +1,6 @@ import type { PackageJson } from 'pkg-types' +import type { UpdateCatalogEntriesResult } from '../utils/catalog' import type { InstallResult } from '../utils/install' import { existsSync } from 'node:fs' @@ -12,7 +13,7 @@ import { dirname, relative, resolve } from 'pathe' import colors from 'picocolors' import { findWorkspaceDir, readPackageJSON } from 'pkg-types' -import { resolveCatalogEntry, setCatalogEntry } from '../utils/catalog' +import { resolveCatalogEntry, updateCatalogEntries } from '../utils/catalog' import { createInstallLog, runDedupe, runInstall, takeUnreportedIgnoredBuilds } from '../utils/install' import { loadKit } from '../utils/kit' import { logger } from '../utils/logger' @@ -52,6 +53,10 @@ export interface InstallSpec { */ export function parseInstallSpec(spec: string): InstallSpec { const separator = spec.lastIndexOf('@') + if (separator < 1) { + return { name: spec, target: spec, range: 'latest', aliased: false } + } + const name = spec.slice(0, separator) const rest = spec.slice(separator + 1) @@ -253,12 +258,14 @@ export default defineCommand({ const verbose = ctx.args.logLevel === 'verbose' || Boolean(process.env.DEBUG) + let catalogsUpdated: UpdateCatalogEntriesResult | false = false + if (catalogUpdates.length > 0) { const catalogSpinner = spinner() catalogSpinner.start('Updating catalog entries') - const updated: string[] = [] + + const resolved: Array<{ catalog: string, pkg: string, specifier: string }> = [] const unresolved: string[] = [] - const failed: string[] = [] for (const { catalog, spec } of catalogUpdates) { const specifier = await resolveCatalogSpecifier(spec).catch(() => undefined) @@ -266,23 +273,35 @@ export default defineCommand({ unresolved.push(spec.name) continue } - const result = setCatalogEntry(cwd, catalog, spec.name, specifier) - if (result === 'failed') { - failed.push(spec.name) - } - else if (result === 'updated') { - updated.push(`${spec.name}@${specifier}`) - } + resolved.push({ catalog, pkg: spec.name, specifier }) } - catalogSpinner.stop(updated.length > 0 ? `Catalog entries updated: ${updated.join(', ')}` : 'No catalog entries updated') + catalogsUpdated = resolved.length > 0 && updateCatalogEntries(cwd, resolved) + const result = catalogsUpdated || 'unchanged' + + catalogSpinner.stop( + result === 'updated' + ? `Catalog entries updated: ${resolved.map(({ pkg, specifier }) => `${pkg}@${specifier}`).join(', ')}` + : 'No catalog entries updated', + ) if (unresolved.length > 0) { logger.warn(`Unable to resolve a ${versionType} version for ${unresolved.map(name => colors.cyan(name)).join(', ')}. Their catalog entries were left unchanged.`) } - if (failed.length > 0) { - logger.warn(`Unable to update the catalog entries for ${failed.map(name => colors.cyan(name)).join(', ')}. Check ${colors.cyan('pnpm-workspace.yaml')} is readable and valid.`) + if (result === 'failed') { + logger.warn(`Unable to update the catalog entries for ${resolved.map(({ pkg }) => colors.cyan(pkg)).join(', ')}. Check ${colors.cyan('pnpm-workspace.yaml')} is readable and valid.`) + } + + // Without a catalog entry pointing at the new version there is nothing for + // the install to upgrade, and reporting success would be a lie. + const nuxtUpgradeFailed = result === 'failed' + ? resolved.some(({ pkg }) => pkg === 'nuxt') + : unresolved.includes('nuxt') + if (nuxtUpgradeFailed) { + logger.error(`Unable to upgrade ${colors.cyan('nuxt')} in ${colors.cyan('pnpm-workspace.yaml')}.`) + outro('Upgrade cancelled.') + process.exit(1) } } @@ -301,6 +320,9 @@ export default defineCommand({ ) if (installFailed) { + if (catalogsUpdated === 'updated') { + logger.info(`Your ${colors.cyan('pnpm-workspace.yaml')} catalog entries were already updated, so review them before retrying.`) + } process.exit(1) } diff --git a/packages/nuxt-cli/src/utils/catalog.ts b/packages/nuxt-cli/src/utils/catalog.ts index e707dc191..e4461f341 100644 --- a/packages/nuxt-cli/src/utils/catalog.ts +++ b/packages/nuxt-cli/src/utils/catalog.ts @@ -47,9 +47,19 @@ export function findPnpmWorkspaceYaml(cwd: string): string | undefined { } } +const configCache = new Map() + +/** Discard memoised catalog configuration, so a later read sees changes on disk. */ +export function clearCatalogCache(): void { + configCache.clear() +} + /** * Catalogs declared in the nearest `pnpm-workspace.yaml`, keyed by catalog name. * The top-level `catalog` key is exposed as {@link DEFAULT_CATALOG}. + * + * Results are memoised per workspace file, as commands such as `nuxi info` query + * dozens of dependencies in a row. */ export function readCatalogConfig(cwd: string): CatalogConfig | undefined { const filePath = findPnpmWorkspaceYaml(cwd) @@ -57,6 +67,16 @@ export function readCatalogConfig(cwd: string): CatalogConfig | undefined { return undefined } + if (configCache.has(filePath)) { + return configCache.get(filePath) + } + + const config = parseCatalogConfig(filePath) + configCache.set(filePath, config) + return config +} + +function parseCatalogConfig(filePath: string): CatalogConfig | undefined { let workspace: ReturnType try { workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) @@ -94,14 +114,20 @@ export function resolveCatalogEntry(cwd: string, pkgJson: PackageJson | null | u return { catalog, specifier: config?.catalogs[catalog]?.[pkg] } } -/** The outcome of a {@link setCatalogEntry} call. */ -export type SetCatalogEntryResult = 'updated' | 'unchanged' | 'failed' +/** The outcome of a {@link updateCatalogEntries} call. */ +export type UpdateCatalogEntriesResult = 'updated' | 'unchanged' | 'failed' + +export interface CatalogEntryUpdate { + catalog: string + pkg: string + specifier: string +} /** - * Point a catalog entry at `specifier`, preserving the comments, anchors and - * aliases in `pnpm-workspace.yaml`. + * Point catalog entries at new specifiers in a single read/write of + * `pnpm-workspace.yaml`, preserving its comments, anchors and aliases. */ -export function setCatalogEntry(cwd: string, catalog: string, pkg: string, specifier: string): SetCatalogEntryResult { +export function updateCatalogEntries(cwd: string, updates: CatalogEntryUpdate[]): UpdateCatalogEntriesResult { const filePath = findPnpmWorkspaceYaml(cwd) if (!filePath) { return 'failed' @@ -109,12 +135,15 @@ export function setCatalogEntry(cwd: string, catalog: string, pkg: string, speci try { const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) - workspace.setPackage(catalog, pkg, specifier) + for (const { catalog, pkg, specifier } of updates) { + workspace.setPackage(catalog, pkg, specifier) + } if (!workspace.hasChanged()) { return 'unchanged' } writeFileSync(filePath, workspace.toString(), 'utf-8') + configCache.delete(filePath) return 'updated' } catch { diff --git a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts index ef2072e53..1d4991613 100644 --- a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts @@ -72,6 +72,11 @@ describe('parseInstallSpec', () => { expect(parseInstallSpec('nuxt@latest')).toEqual({ name: 'nuxt', target: 'nuxt', range: 'latest', aliased: false }) }) + it('should default to the latest dist-tag for a bare package name', () => { + expect(parseInstallSpec('nuxt')).toEqual({ name: 'nuxt', target: 'nuxt', range: 'latest', aliased: false }) + expect(parseInstallSpec('@nuxt/kit')).toEqual({ name: '@nuxt/kit', target: '@nuxt/kit', range: 'latest', aliased: false }) + }) + it('should split a scoped package from its range', () => { expect(parseInstallSpec('@nuxt/kit@3')).toEqual({ name: '@nuxt/kit', target: '@nuxt/kit', range: '3', aliased: false }) }) diff --git a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts index af60ba26f..a60f08aa3 100644 --- a/packages/nuxt-cli/test/unit/utils/catalog.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { findPnpmWorkspaceYaml, parseCatalogSpecifier, readCatalogConfig, resolveCatalogEntry, setCatalogEntry } from '../../../src/utils/catalog' +import { clearCatalogCache, findPnpmWorkspaceYaml, parseCatalogSpecifier, readCatalogConfig, resolveCatalogEntry, updateCatalogEntries } from '../../../src/utils/catalog' describe('parseCatalogSpecifier', () => { it('should treat a bare `catalog:` as the default catalog', () => { @@ -28,6 +28,7 @@ describe('catalog resolution', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'nuxt-catalog-test-')) + clearCatalogCache() }) afterEach(async () => { @@ -111,11 +112,12 @@ describe('catalog resolution', () => { }) }) -describe('setCatalogEntry', () => { +describe('updateCatalogEntries', () => { let tempDir: string beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'nuxt-catalog-test-')) + clearCatalogCache() }) afterEach(async () => { @@ -126,31 +128,51 @@ describe('setCatalogEntry', () => { const filePath = join(tempDir, 'pnpm-workspace.yaml') await writeFile(filePath, '# pinned by policy\ncatalog:\n nuxt: ^4.1.0\n vue: ^3.6.0\n') - expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.2.0')).toBe('updated') + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated') expect(await readFile(filePath, 'utf-8')).toBe('# pinned by policy\ncatalog:\n nuxt: ^4.2.0\n vue: ^3.6.0\n') }) + it('should update entries across catalogs in a single write', async () => { + const filePath = join(tempDir, 'pnpm-workspace.yaml') + await writeFile(filePath, 'catalog:\n nuxt: ^4.1.0\ncatalogs:\n prod:\n "@nuxt/kit": ^4.1.0\n') + + expect(updateCatalogEntries(tempDir, [ + { catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }, + { catalog: 'prod', pkg: '@nuxt/kit', specifier: '^4.2.0' }, + ])).toBe('updated') + expect(await readFile(filePath, 'utf-8')).toBe('catalog:\n nuxt: ^4.2.0\ncatalogs:\n prod:\n "@nuxt/kit": ^4.2.0\n') + }) + it('should update an entry in a named catalog', async () => { const filePath = join(tempDir, 'pnpm-workspace.yaml') await writeFile(filePath, 'catalogs:\n prod:\n nuxt: ^4.1.0\n') - expect(setCatalogEntry(tempDir, 'prod', 'nuxt', 'npm:nuxt-nightly@^4.3.0')).toBe('updated') + expect(updateCatalogEntries(tempDir, [{ catalog: 'prod', pkg: 'nuxt', specifier: 'npm:nuxt-nightly@^4.3.0' }])).toBe('updated') expect(await readFile(filePath, 'utf-8')).toBe('catalogs:\n prod:\n nuxt: npm:nuxt-nightly@^4.3.0\n') }) - it('should report an entry that already matches as unchanged', async () => { + it('should report entries that already match as unchanged', async () => { await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.2.0\n') - expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.2.0')).toBe('unchanged') + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('unchanged') + }) + + it('should invalidate cached catalog config after a write', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.1.0\n') + + expect(readCatalogConfig(tempDir)?.catalogs.default).toEqual({ nuxt: '^4.1.0' }) + updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }]) + + expect(readCatalogConfig(tempDir)?.catalogs.default).toEqual({ nuxt: '^4.2.0' }) }) it('should fail when there is no workspace file', () => { - expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.2.0')).toBe('failed') + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('failed') }) it('should fail when the workspace file cannot be parsed', async () => { await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: "^4.2.0\n\tbad: [\n') - expect(setCatalogEntry(tempDir, 'default', 'nuxt', '^4.3.0')).toBe('failed') + expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.3.0' }])).toBe('failed') }) }) From adcefeff04d1b976b88ab166dd47b02d315f1255 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 26 Jul 2026 20:16:34 +0100 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20more=20=F0=9F=90=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/nuxt-cli/src/commands/upgrade.ts | 49 ++++++++++++------- packages/nuxt-cli/src/utils/versions.ts | 36 +++++++++----- .../test/unit/commands/upgrade.spec.ts | 18 +++++++ .../nuxt-cli/test/unit/utils/versions.spec.ts | 6 +++ 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/packages/nuxt-cli/src/commands/upgrade.ts b/packages/nuxt-cli/src/commands/upgrade.ts index d03599bf2..f5a34a16b 100644 --- a/packages/nuxt-cli/src/commands/upgrade.ts +++ b/packages/nuxt-cli/src/commands/upgrade.ts @@ -68,17 +68,30 @@ export function parseInstallSpec(spec: string): InstallSpec { return { name, target: name, range: rest, aliased: false } } +const CATALOG_RANGE_RE = /^(?:npm:.+@)?([~^]?)\d+\.\d+\.\d+(?:[-+].+)?$/ + +/** + * The range operator to keep when rewriting `specifier`, so an entry pinned to an + * exact version stays pinned. Anything that is not a plain version (a range such + * as `>=4.0.0`, or no entry at all) gets a caret, as `pnpm add` would write. + */ +export function resolveRangePrefix(specifier: string | undefined): string { + return specifier?.match(CATALOG_RANGE_RE)?.[1] ?? '^' +} + /** * The specifier a catalog entry should hold for `spec`. pnpm resolves dist-tags * and ranges itself when it writes to `package.json`, but a catalog entry we - * write ourselves has to name a concrete version. + * write ourselves has to name a concrete version. `current` is the specifier the + * entry holds today, whose range operator is preserved. */ -export async function resolveCatalogSpecifier(spec: InstallSpec): Promise { +export async function resolveCatalogSpecifier(spec: InstallSpec, current?: string): Promise { const version = await resolveRegistryVersion(spec.target, spec.range) if (!version) { return undefined } - return spec.aliased ? `npm:${spec.target}@^${version}` : `^${version}` + const range = `${resolveRangePrefix(current)}${version}` + return spec.aliased ? `npm:${spec.target}@${range}` : range } const nuxtVersionTags = { @@ -192,12 +205,12 @@ export default defineCommand({ // A `catalog:` dependency has to be upgraded in `pnpm-workspace.yaml`: asking // pnpm to add a pinned version instead replaces the reference in // `package.json`, silently taking the dependency out of the catalog. - const catalogUpdates: Array<{ catalog: string, spec: InstallSpec }> = [] + const catalogUpdates: Array<{ catalog: string, current?: string, spec: InstallSpec }> = [] const directPackages: string[] = [] for (const [index, name] of packageNames.entries()) { - const catalog = packageManagerName === 'pnpm' ? resolveCatalogEntry(cwd, pkg, name)?.catalog : undefined - if (catalog) { - catalogUpdates.push({ catalog, spec: parseInstallSpec(npmPackages[index]!) }) + const entry = packageManagerName === 'pnpm' ? resolveCatalogEntry(cwd, pkg, name) : undefined + if (entry) { + catalogUpdates.push({ catalog: entry.catalog, current: entry.specifier, spec: parseInstallSpec(npmPackages[index]!) }) } else { directPackages.push(npmPackages[index]!) @@ -258,7 +271,7 @@ export default defineCommand({ const verbose = ctx.args.logLevel === 'verbose' || Boolean(process.env.DEBUG) - let catalogsUpdated: UpdateCatalogEntriesResult | false = false + let catalogResult: UpdateCatalogEntriesResult | 'skipped' = 'skipped' if (catalogUpdates.length > 0) { const catalogSpinner = spinner() @@ -267,8 +280,8 @@ export default defineCommand({ const resolved: Array<{ catalog: string, pkg: string, specifier: string }> = [] const unresolved: string[] = [] - for (const { catalog, spec } of catalogUpdates) { - const specifier = await resolveCatalogSpecifier(spec).catch(() => undefined) + for (const { catalog, current, spec } of catalogUpdates) { + const specifier = await resolveCatalogSpecifier(spec, current) if (!specifier) { unresolved.push(spec.name) continue @@ -276,26 +289,28 @@ export default defineCommand({ resolved.push({ catalog, pkg: spec.name, specifier }) } - catalogsUpdated = resolved.length > 0 && updateCatalogEntries(cwd, resolved) - const result = catalogsUpdated || 'unchanged' + if (resolved.length > 0) { + catalogResult = updateCatalogEntries(cwd, resolved) + } catalogSpinner.stop( - result === 'updated' + catalogResult === 'updated' ? `Catalog entries updated: ${resolved.map(({ pkg, specifier }) => `${pkg}@${specifier}`).join(', ')}` : 'No catalog entries updated', ) if (unresolved.length > 0) { - logger.warn(`Unable to resolve a ${versionType} version for ${unresolved.map(name => colors.cyan(name)).join(', ')}. Their catalog entries were left unchanged.`) + logger.warn(`Unable to resolve a ${versionType} version for ${unresolved.map(name => colors.cyan(name)).join(', ')} from the npm registry. Their catalog entries were left unchanged.`) + logger.info(`Run with ${colors.cyan('DEBUG=nuxi')} to see why the lookup failed.`) } - if (result === 'failed') { + if (catalogResult === 'failed') { logger.warn(`Unable to update the catalog entries for ${resolved.map(({ pkg }) => colors.cyan(pkg)).join(', ')}. Check ${colors.cyan('pnpm-workspace.yaml')} is readable and valid.`) } // Without a catalog entry pointing at the new version there is nothing for // the install to upgrade, and reporting success would be a lie. - const nuxtUpgradeFailed = result === 'failed' + const nuxtUpgradeFailed = catalogResult === 'failed' ? resolved.some(({ pkg }) => pkg === 'nuxt') : unresolved.includes('nuxt') if (nuxtUpgradeFailed) { @@ -320,7 +335,7 @@ export default defineCommand({ ) if (installFailed) { - if (catalogsUpdated === 'updated') { + if (catalogResult === 'updated') { logger.info(`Your ${colors.cyan('pnpm-workspace.yaml')} catalog entries were already updated, so review them before retrying.`) } process.exit(1) diff --git a/packages/nuxt-cli/src/utils/versions.ts b/packages/nuxt-cli/src/utils/versions.ts index 4bcf3f8c1..cf3989c66 100644 --- a/packages/nuxt-cli/src/utils/versions.ts +++ b/packages/nuxt-cli/src/utils/versions.ts @@ -7,8 +7,12 @@ import { coerce, findMaxSatisfying } from 'verkit' import { resolveCatalogEntry } from './catalog' import { tryResolveNuxt } from './kit' +import { debug } from './logger' import { detectNpmRegistry } from './registry' +/** How long to wait on the registry before giving up on a version lookup. */ +const FETCH_TIMEOUT = 10_000 + /** * Names a resolved `nuxt` dependency can legitimately have, so that a * `package.json` reached through `pkg-types`' nearest-file fallback (which @@ -32,20 +36,30 @@ export async function getNuxtVersion(cwd: string, cache = true) { /** * The highest published version of `pkg` matching `range`, which may be a - * dist-tag (`latest`) or a semver range (`4`). + * dist-tag (`latest`) or a semver range (`4`). Returns `undefined` when the + * range matches nothing or the registry cannot be reached. */ export async function resolveRegistryVersion(pkg: string, range: string): Promise { - const scope = pkg.startsWith('@') ? pkg.split('/')[0]! : null - const { registry, authToken } = await detectNpmRegistry(scope) + let packument: { 'dist-tags'?: Record, 'versions'?: Record } + try { + const scope = pkg.startsWith('@') ? pkg.split('/')[0]! : null + const { registry, authToken } = await detectNpmRegistry(scope) - const packument = await $fetch<{ 'dist-tags'?: Record, 'versions'?: Record }>(joinURL(registry, pkg), { - headers: { - // The abbreviated packument is a fraction of the size of the full one and - // still carries every version and dist-tag. - Accept: 'application/vnd.npm.install-v1+json', - ...authToken ? { Authorization: `Bearer ${authToken}` } : {}, - }, - }) + packument = await $fetch(joinURL(registry, pkg), { + headers: { + // The abbreviated packument is a fraction of the size of the full one and + // still carries every version and dist-tag. + Accept: 'application/vnd.npm.install-v1+json', + ...authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }, + timeout: FETCH_TIMEOUT, + retry: 0, + }) + } + catch (error) { + debug(`Failed to resolve a version of ${pkg} matching ${range}:`, error) + return undefined + } return packument['dist-tags']?.[range] // the registry lists versions in publication order, so a backported patch can diff --git a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts index 1d4991613..8b17427b8 100644 --- a/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/upgrade.spec.ts @@ -114,6 +114,24 @@ describe('resolveCatalogSpecifier', () => { expect(await resolveCatalogSpecifier({ name: 'nuxt', target: 'nuxt-nightly', range: 'latest', aliased: true })).toBe('npm:nuxt-nightly@^4.3.0-28991214-abcdef') }) + it('should keep the range operator of the entry it replaces', async () => { + vi.mocked(resolveRegistryVersion).mockResolvedValue('4.2.1') + const spec = { name: 'nuxt', target: 'nuxt', range: 'latest', aliased: false } + + expect(await resolveCatalogSpecifier(spec, '4.1.0')).toBe('4.2.1') + expect(await resolveCatalogSpecifier(spec, '~4.1.0')).toBe('~4.2.1') + expect(await resolveCatalogSpecifier(spec, '^4.1.0')).toBe('^4.2.1') + expect(await resolveCatalogSpecifier(spec, '>=4.0.0')).toBe('^4.2.1') + }) + + it('should keep the range operator of an aliased entry', async () => { + vi.mocked(resolveRegistryVersion).mockResolvedValue('4.3.0-28991214-abcdef') + const spec = { name: 'nuxt', target: 'nuxt-nightly', range: 'latest', aliased: true } + + expect(await resolveCatalogSpecifier(spec, 'npm:nuxt-nightly@4.2.0-28991213-abcdef')).toBe('npm:nuxt-nightly@4.3.0-28991214-abcdef') + expect(await resolveCatalogSpecifier(spec, 'npm:nuxt-nightly@^4.2.0')).toBe('npm:nuxt-nightly@^4.3.0-28991214-abcdef') + }) + it('should return nothing when no version matches', async () => { vi.mocked(resolveRegistryVersion).mockResolvedValue(undefined) diff --git a/packages/nuxt-cli/test/unit/utils/versions.spec.ts b/packages/nuxt-cli/test/unit/utils/versions.spec.ts index 39a6106d7..c152b43e2 100644 --- a/packages/nuxt-cli/test/unit/utils/versions.spec.ts +++ b/packages/nuxt-cli/test/unit/utils/versions.spec.ts @@ -72,4 +72,10 @@ describe('resolveRegistryVersion', () => { expect(await resolveRegistryVersion('nuxt', '99')).toBeUndefined() }) + + it('should return nothing when the registry cannot be reached', async () => { + vi.mocked($fetch).mockRejectedValue(new Error('ECONNREFUSED')) + + expect(await resolveRegistryVersion('nuxt', 'latest')).toBeUndefined() + }) })