diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 831132a1..a8a2a473 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 70450e44..b8a62e56 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 adbca7e1..f5a34a16 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,13 +13,14 @@ import { dirname, relative, resolve } from 'pathe' import colors from 'picocolors' import { findWorkspaceDir, readPackageJSON } from 'pkg-types' +import { resolveCatalogEntry, updateCatalogEntries } 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 +33,67 @@ 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('@') + if (separator < 1) { + return { name: spec, target: spec, range: 'latest', aliased: false } + } + + 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 } +} + +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. `current` is the specifier the + * entry holds today, whose range operator is preserved. + */ +export async function resolveCatalogSpecifier(spec: InstallSpec, current?: string): Promise { + const version = await resolveRegistryVersion(spec.target, spec.range) + if (!version) { + return undefined + } + const range = `${resolveRangePrefix(current)}${version}` + return spec.aliased ? `npm:${spec.target}@${range}` : range +} + const nuxtVersionTags = { '3.x': '3x', '4.x': 'latest', @@ -136,7 +199,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, current?: string, spec: InstallSpec }> = [] + const directPackages: string[] = [] + for (const [index, name] of packageNames.entries()) { + 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]!) + } + } // Force install const toRemove = ['node_modules'] @@ -192,6 +271,55 @@ export default defineCommand({ const verbose = ctx.args.logLevel === 'verbose' || Boolean(process.env.DEBUG) + let catalogResult: UpdateCatalogEntriesResult | 'skipped' = 'skipped' + + if (catalogUpdates.length > 0) { + const catalogSpinner = spinner() + catalogSpinner.start('Updating catalog entries') + + const resolved: Array<{ catalog: string, pkg: string, specifier: string }> = [] + const unresolved: string[] = [] + + for (const { catalog, current, spec } of catalogUpdates) { + const specifier = await resolveCatalogSpecifier(spec, current) + if (!specifier) { + unresolved.push(spec.name) + continue + } + resolved.push({ catalog, pkg: spec.name, specifier }) + } + + if (resolved.length > 0) { + catalogResult = updateCatalogEntries(cwd, resolved) + } + + catalogSpinner.stop( + 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(', ')} 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 (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 = catalogResult === '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) + } + } + const installFailed = await withInstallSpinner( `Installing ${versionType} Nuxt ${nuxtVersion} release`, 'Nuxt packages installed', @@ -199,7 +327,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, @@ -207,6 +335,9 @@ export default defineCommand({ ) if (installFailed) { + 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/catalog.ts b/packages/nuxt-cli/src/utils/catalog.ts new file mode 100644 index 00000000..e4461f34 --- /dev/null +++ b/packages/nuxt-cli/src/utils/catalog.ts @@ -0,0 +1,152 @@ +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 + } + } +} + +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) + if (!filePath) { + 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')) + } + 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] } +} + +/** The outcome of a {@link updateCatalogEntries} call. */ +export type UpdateCatalogEntriesResult = 'updated' | 'unchanged' | 'failed' + +export interface CatalogEntryUpdate { + catalog: string + pkg: string + specifier: string +} + +/** + * Point catalog entries at new specifiers in a single read/write of + * `pnpm-workspace.yaml`, preserving its comments, anchors and aliases. + */ +export function updateCatalogEntries(cwd: string, updates: CatalogEntryUpdate[]): UpdateCatalogEntriesResult { + const filePath = findPnpmWorkspaceYaml(cwd) + if (!filePath) { + return 'failed' + } + + try { + const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8')) + 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 { + return 'failed' + } +} diff --git a/packages/nuxt-cli/src/utils/versions.ts b/packages/nuxt-cli/src/utils/versions.ts index fb118446..cf3989c6 100644 --- a/packages/nuxt-cli/src/utils/versions.ts +++ b/packages/nuxt-cli/src/utils/versions.ts @@ -1,9 +1,17 @@ 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, 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 @@ -21,10 +29,44 @@ 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`). Returns `undefined` when the + * range matches nothing or the registry cannot be reached. + */ +export async function resolveRegistryVersion(pkg: string, range: string): Promise { + let packument: { 'dist-tags'?: Record, 'versions'?: Record } + try { + const scope = pkg.startsWith('@') ? pkg.split('/')[0]! : null + const { registry, authToken } = await detectNpmRegistry(scope) + + 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 + // 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[] }) { 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 56238002..8b17427b 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 } 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 @@ -60,3 +66,75 @@ 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 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 }) + }) + + 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, + }) + }) +}) + +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 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) + + 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 new file mode 100644 index 00000000..a60f08aa --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/catalog.spec.ts @@ -0,0 +1,178 @@ +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 { clearCatalogCache, findPnpmWorkspaceYaml, parseCatalogSpecifier, readCatalogConfig, resolveCatalogEntry, updateCatalogEntries } 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-')) + clearCatalogCache() + }) + + 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() + }) +}) + +describe('updateCatalogEntries', () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'nuxt-catalog-test-')) + clearCatalogCache() + }) + + 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(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(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 entries that already match as unchanged', async () => { + await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog:\n nuxt: ^4.2.0\n') + + 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(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(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^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 44d5926a..c152b43e 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 @@ -22,4 +27,55 @@ 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') + }) +}) + +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() + }) + + it('should return nothing when the registry cannot be reached', async () => { + vi.mocked($fetch).mockRejectedValue(new Error('ECONNREFUSED')) + + expect(await resolveRegistryVersion('nuxt', 'latest')).toBeUndefined() + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc05e924..d2fe10fc 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