Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nuxt-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions packages/nuxt-cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -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<NuxtConfig['modules']> = []) {
Expand Down
137 changes: 134 additions & 3 deletions packages/nuxt-cli/src/commands/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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' {
Expand All @@ -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<string | undefined> {
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',
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -192,21 +271,73 @@ 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)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const installFailed = await withInstallSpinner(
`Installing ${versionType} Nuxt ${nuxtVersion} release`,
'Nuxt packages installed',
{ verbose },
hooks => runInstall({
cwd,
packageManager,
dependencies: npmPackages,
dependencies: directPackages,
dev: nuxtDependencyType === 'devDependencies',
workspace: packageManager.name === 'pnpm' && existsSync(resolve(cwd, 'pnpm-workspace.yaml')),
...hooks,
}),
)

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)
}

Expand Down
152 changes: 152 additions & 0 deletions packages/nuxt-cli/src/utils/catalog.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, string>>
}

/**
* 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<string, CatalogConfig | undefined>()

/** 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<typeof parsePnpmWorkspaceYaml>
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] }
Comment on lines +106 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 --glob '*.ts' \
  'resolveCatalogEntry\(|optionalDependencies|peerDependencies' \
  packages/nuxt-cli/src packages/nuxt-cli/test

Repository: nuxt/cli

Length of output: 14300


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- packages/nuxt-cli/src/utils/catalog.ts outline ---\n'
ast-grep outline packages/nuxt-cli/src/utils/catalog.ts --view expanded || true

printf '\n--- packages/nuxt-cli/src/utils/catalog.ts relevant lines ---\n'
sed -n '1,150p' packages/nuxt-cli/src/utils/catalog.ts | cat -n

printf '\n--- packages/nuxt-cli/src/utils/versions.ts ---\n'
sed -n '1,60p' packages/nuxt-cli/src/utils/versions.ts | cat -n

printf '\n--- pnpm catalog references in package manifests ---\n'
rg -n '"catalog:("' -g 'package.json' .

Repository: nuxt/cli

Length of output: 237


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- packages/nuxt-cli/src/utils/catalog.ts outline ---'
ast-grep outline packages/nuxt-cli/src/utils/catalog.ts --view expanded || true

printf '%s\n' ''
printf '%s\n' '--- packages/nuxt-cli/src/utils/catalog.ts relevant lines ---'
sed -n '1,150p' packages/nuxt-cli/src/utils/catalog.ts | cat -n

printf '%s\n' ''
printf '%s\n' '--- packages/nuxt-cli/src/utils/versions.ts ---'
sed -n '1,60p' packages/nuxt-cli/src/utils/versions.ts | cat -n

printf '%s\n' ''
printf '%s\n' '--- pnpm catalog references in package manifests ---'
rg -n '"catalog:("' -g 'package.json' . || true

Repository: nuxt/cli

Length of output: 10135


🌐 Web query:

pnpm catalog references dependency sections dependencies devDependencies peerDependencies optionalDependencies workspace.yaml

💡 Result:

In pnpm, catalogs are a workspace feature that allows you to define dependency version ranges centrally in your pnpm-workspace.yaml file and reference them across your workspace packages [1][2]. You can use the catalog: protocol to reference these versions in the following dependency sections within any package.json file in your workspace: - dependencies [3] - devDependencies [3] - peerDependencies [3][4] - optionalDependencies [3] Additionally, the catalog: protocol can be used within the overrides field inside your pnpm-workspace.yaml file [5][3]. How it works: 1. Define your catalogs in pnpm-workspace.yaml using either the singular catalog field (for the default catalog) or the plural catalogs field (for named catalogs) [1][2]. 2. Reference the catalog version in a package.json file using the catalog: protocol [1][3]. - For the default catalog, you can use the shorthand catalog: [1][6]. - For named catalogs, use the syntax catalog:<catalog_name> (e.g., catalog:react18) [1][6]. Example: In pnpm-workspace.yaml: catalog: react: ^18.3.1 catalogs: testing: vitest: ^2.0.0 In a package.json: { "dependencies": { "react": "catalog:" }, "devDependencies": { "vitest": "catalog:testing" } } You can also add dependencies to catalogs directly via the CLI using pnpm add --save-catalog or pnpm add --save-catalog-name <catalog_name> [7].

Citations:


Resolve catalog references from all dependency sections.

resolveCatalogEntry() currently ignores optionalDependencies and peerDependencies, but pnpm catalogs are valid in those sections too. This can make catalog-managed packages bypass the workspace catalog value, including in the Nuxt version fallback.

Proposed fix
-  const specifier = pkgJson?.dependencies?.[pkg] || pkgJson?.devDependencies?.[pkg]
+  const specifier = pkgJson?.dependencies?.[pkg]
+    ?? pkgJson?.devDependencies?.[pkg]
+    ?? pkgJson?.optionalDependencies?.[pkg]
+    ?? pkgJson?.peerDependencies?.[pkg]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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] }
export function resolveCatalogEntry(cwd: string, pkgJson: PackageJson | null | undefined, pkg: string): CatalogEntry | undefined {
const specifier = pkgJson?.dependencies?.[pkg]
?? pkgJson?.devDependencies?.[pkg]
?? pkgJson?.optionalDependencies?.[pkg]
?? pkgJson?.peerDependencies?.[pkg]
const catalog = parseCatalogSpecifier(specifier)
if (!catalog) {
return undefined
}
const config = readCatalogConfig(cwd)
return { catalog, specifier: config?.catalogs[catalog]?.[pkg] }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxt-cli/src/utils/catalog.ts` around lines 106 - 114, Update
resolveCatalogEntry to resolve the package specifier across dependencies,
devDependencies, optionalDependencies, and peerDependencies, preserving the
existing precedence and catalog lookup behavior so catalog-managed packages in
any supported section use the workspace catalog value.

}

/** 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'
}
}
Loading
Loading