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: 0 additions & 1 deletion packages/nuxt-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
"pathe": "^2.0.3",
"perfect-debounce": "^2.1.0",
"pkg-types": "^2.3.1",
"pnpm-workspace-yaml": "^1.7.0",
"rc9": "^3.0.1",
"scule": "^1.3.0",
"source-map-js": "^1.2.1",
Expand Down
273 changes: 262 additions & 11 deletions packages/nuxt-cli/src/utils/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import type { PackageJson } from 'pkg-types'

import { existsSync, readFileSync, writeFileSync } from 'node:fs'

import { parseYAML } from 'confbox/yaml'
import { dirname, join, resolve } from 'pathe'
import { parsePnpmWorkspaceYaml } from 'pnpm-workspace-yaml'

const CATALOG_SPECIFIER_RE = /^catalog:(.*)$/

Expand Down Expand Up @@ -76,16 +76,20 @@ export function readCatalogConfig(cwd: string): CatalogConfig | undefined {
return config
}

interface WorkspaceYaml {
catalog?: Record<string, string>
catalogs?: Record<string, Record<string, string>>
}

function parseCatalogConfig(filePath: string): CatalogConfig | undefined {
let workspace: ReturnType<typeof parsePnpmWorkspaceYaml>
let json: WorkspaceYaml
try {
workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8'))
json = parseYAML<WorkspaceYaml>(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
Expand Down Expand Up @@ -133,20 +137,267 @@ export function updateCatalogEntries(cwd: string, updates: CatalogEntryUpdate[])
return 'failed'
}

let source: string
try {
const workspace = parsePnpmWorkspaceYaml(readFileSync(filePath, 'utf-8'))
for (const { catalog, pkg, specifier } of updates) {
workspace.setPackage(catalog, pkg, specifier)
source = readFileSync(filePath, 'utf-8')
// Reject anything we cannot understand before rewriting a line of it.
parseYAML(source)
}
catch {
return 'failed'
}

const lines = source.split('\n')
let changed = false

for (const { catalog, pkg, specifier } of updates) {
const result = setCatalogEntry(lines, catalog, pkg, specifier)
if (result === 'failed') {
return 'failed'
}
if (!workspace.hasChanged()) {
changed ||= result === 'updated'
}

if (!changed) {
return 'unchanged'
}

try {
writeFileSync(filePath, lines.join('\n'), 'utf-8')
}
catch {
return 'failed'
}

configCache.delete(filePath)
return 'updated'
}

/**
* A `key:` line, split into its indentation, raw (possibly quoted) key and the
* inline value that follows. Blank lines, comments and sequence items do not match.
*/
const KEY_LINE_RE = /^(\s*)(?:(?<quote>["'])(?<quoted>(?:\\.|(?!\k<quote>).)*)\k<quote>\s*|(?<plain>[^#\s"'][^:]*)):(?<rest>.*)$/

interface KeyLine {
indent: number
key: string
/** The key exactly as written, including quotes. */
raw: string
value: string
}

function parseKeyLine(line: string): KeyLine | undefined {
const match = line.match(KEY_LINE_RE)
if (!match?.groups) {
return undefined
}
const { quote, quoted, plain, rest } = match.groups
if (rest !== '' && !rest!.startsWith(' ')) {
return undefined
}
const key = quote ? quoted! : plain!.trimEnd()
return {
indent: match[1]!.length,
key: quote ? unescapeYAMLString(key, quote) : key,
raw: quote ? `${quote}${quoted}${quote}` : key,
value: rest!.trimStart(),
}
}

function unescapeYAMLString(value: string, quote: string): string {
return quote === '\'' ? value.replaceAll('\'\'', '\'') : JSON.parse(`"${value}"`)
}
Comment on lines +199 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard unescapeYAMLString against a JSON.parse throw.

JSON.parse rejects YAML-only escapes such as \x41, \e, or \_. parseKeyLine runs on every line of the workspace file, including lines the caller does not target. A double-quoted key with such an escape makes JSON.parse throw. The throw escapes updateCatalogEntries, because the try block there only wraps the read and the initial parse. The documented contract is to return 'failed' instead.

🛡️ Proposed fix
 function unescapeYAMLString(value: string, quote: string): string {
-  return quote === '\'' ? value.replaceAll('\'\'', '\'') : JSON.parse(`"${value}"`)
+  if (quote === '\'') {
+    return value.replaceAll('\'\'', '\'')
+  }
+  try {
+    return JSON.parse(`"${value}"`)
+  }
+  catch {
+    return value
+  }
 }
📝 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
const key = quote ? quoted! : plain!.trimEnd()
return {
indent: match[1]!.length,
key: quote ? unescapeYAMLString(key, quote) : key,
raw: quote ? `${quote}${quoted}${quote}` : key,
value: rest!.trimStart(),
}
}
function unescapeYAMLString(value: string, quote: string): string {
return quote === '\'' ? value.replaceAll('\'\'', '\'') : JSON.parse(`"${value}"`)
}
const key = quote ? quoted! : plain!.trimEnd()
return {
indent: match[1]!.length,
key: quote ? unescapeYAMLString(key, quote) : key,
raw: quote ? `${quote}${quoted}${quote}` : key,
value: rest!.trimStart(),
}
}
function unescapeYAMLString(value: string, quote: string): string {
if (quote === '\'') {
return value.replaceAll('\'\'', '\'')
}
try {
return JSON.parse(`"${value}"`)
}
catch {
return value
}
}
🤖 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 199 - 210, Update
unescapeYAMLString to handle JSON.parse failures from YAML-only escape sequences
without throwing, and return a safe unescaped value consistent with the
documented failed-result flow. Ensure parseKeyLine cannot propagate this
exception through updateCatalogEntries, while preserving existing single-quoted
handling and valid double-quoted escape behavior.


function isBlankOrComment(line: string): boolean {
const trimmed = line.trim()
return trimmed === '' || trimmed.startsWith('#')
}

/** Split an inline value into the scalar itself and any trailing comment. */
function splitTrailingComment(value: string): [scalar: string, comment: string] {
if (value.startsWith('#')) {
return ['', value]
}
const match = value.match(/\s+#.*$/)
if (!match) {
return [value, '']
}
return [value.slice(0, match.index), match[0]!]
}

/**
* Rewrite (or add) `pkg: specifier` inside `catalog`, editing only the line that
* declares it so surrounding comments, anchors and formatting survive.
*/
function setCatalogEntry(lines: string[], catalog: string, pkg: string, specifier: string): UpdateCatalogEntriesResult {
const block = findBlock(lines, catalogPath(catalog))
if (block === 'failed') {
return 'failed'
}

if (!block) {
return insertCatalogBlock(lines, catalog, pkg, specifier)
}

const { start, end, indent } = block
let entryIndent: number | undefined
let lastEntry = start

for (let index = start + 1; index < end; index++) {
const line = lines[index]!
if (isBlankOrComment(line)) {
continue
}
const entry = parseKeyLine(line)
if (!entry || entry.indent <= indent) {
return 'failed'
}
entryIndent ??= entry.indent
if (entry.indent !== entryIndent) {
continue
}
lastEntry = index
if (entry.key !== pkg) {
continue
}

const [scalar, comment] = splitTrailingComment(entry.value)
const anchor = scalar.match(/^&\S+\s+/)?.[0] ?? ''
const current = scalar.slice(anchor.length)
if (current.startsWith('*')) {
// Rewriting an alias would silently retarget every other use of the anchor.
return 'failed'
}
if (current === specifier || current === `"${specifier}"` || current === `'${specifier}'`) {
return 'unchanged'
}

writeFileSync(filePath, workspace.toString(), 'utf-8')
configCache.delete(filePath)
lines[index] = `${' '.repeat(entry.indent)}${entry.raw}: ${anchor}${quoteYAMLScalar(specifier)}${comment}`
return 'updated'
}
catch {

lines.splice(lastEntry + 1, 0, `${' '.repeat(entryIndent ?? indent + 2)}${quoteYAMLKey(pkg)}: ${quoteYAMLScalar(specifier)}`)
return 'updated'
}

interface CatalogBlock {
/** Index of the `catalog:` / `<name>:` line itself. */
start: number
/** Index one past the last line belonging to the block. */
end: number
indent: number
}

function catalogPath(catalog: string): string[] {
return catalog === DEFAULT_CATALOG ? ['catalog'] : ['catalogs', catalog]
}

function findBlock(lines: string[], path: string[]): CatalogBlock | undefined | 'failed' {
let start = -1
let indent = 0
let end = lines.length

for (const [depth, segment] of path.entries()) {
const from = start + 1
const parentIndent = indent
let childIndent: number | undefined
start = -1
for (let index = from; index < end; index++) {
const line = lines[index]!
if (isBlankOrComment(line)) {
continue
}
const key = parseKeyLine(line)
if (!key) {
continue
}
if (depth === 0) {
if (key.indent !== 0) {
continue
}
}
else {
if (key.indent <= parentIndent) {
break
}
childIndent ??= key.indent
if (key.indent !== childIndent) {
continue
}
}
if (key.key !== segment) {
continue
}
if (splitTrailingComment(key.value)[0] !== '') {
// A flow mapping, alias or anchored value is not safe to edit by line.
return 'failed'
}
start = index
indent = key.indent
end = findBlockEnd(lines, index, indent)
break
}
if (start === -1) {
return undefined
}
}

return { start, end, indent }
}

function findBlockEnd(lines: string[], start: number, indent: number): number {
let end = start + 1
for (let index = start + 1; index < lines.length; index++) {
const line = lines[index]!
if (isBlankOrComment(line)) {
continue
}
if (line.search(/\S/) <= indent) {
break
}
end = index + 1
}
return end
}

function insertCatalogBlock(lines: string[], catalog: string, pkg: string, specifier: string): UpdateCatalogEntriesResult {
const entry = `${quoteYAMLKey(pkg)}: ${quoteYAMLScalar(specifier)}`

if (catalog === DEFAULT_CATALOG) {
return appendLines(lines, ['catalog:', ` ${entry}`])
}

const catalogs = findBlock(lines, ['catalogs'])
if (catalogs === 'failed') {
return 'failed'
}
if (!catalogs) {
return appendLines(lines, ['catalogs:', ` ${catalog}:`, ` ${entry}`])
}

lines.splice(catalogs.end, 0, `${' '.repeat(catalogs.indent + 2)}${catalog}:`, `${' '.repeat(catalogs.indent + 4)}${entry}`)
return 'updated'
Comment on lines +371 to +380

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Derive the child indent from the existing catalogs children.

Line 379 hardcodes catalogs.indent + 2 for the new catalog name. If the file already indents the children of catalogs by 4 spaces, the inserted key sits at indent 2 while its siblings sit at indent 4. YAML requires all keys of one mapping to share the same indentation, so the resulting file fails to parse.

Reproduce with:

catalogs:
    dev:
        typescript: ^5.9.0

Return the child indent from findBlock (or scan the block for the first entry line) and use it here, with + 2 only as the fallback for an empty block. Add a 4-space test case in packages/nuxt-cli/test/unit/utils/catalog.spec.ts.

🤖 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 371 - 380, The catalog
insertion logic around findBlock must use the existing catalogs child
indentation instead of always using catalogs.indent + 2. Return or derive the
child indent from findBlock (using the first child entry when present), fall
back to catalogs.indent + 2 only for an empty block, and apply that indent to
both the new catalog key and entry; add coverage for a four-space-indented
catalogs block in the catalog utility tests.

}

function appendLines(lines: string[], toAppend: string[]): UpdateCatalogEntriesResult {
while (lines.length > 0 && lines.at(-1)!.trim() === '') {
lines.pop()
}
lines.push(...toAppend, '')
return 'updated'
}

const PLAIN_KEY_RE = /^[\w.][\w.\-/]*$/

function quoteYAMLKey(key: string): string {
return PLAIN_KEY_RE.test(key) ? key : JSON.stringify(key)
}

function quoteYAMLScalar(value: string): string {
const needsQuotes = value === ''
|| /^[-?:,[\]{}#&*!|>'"%@`]/.test(value)
|| /:\s|\s#|[\n\t]/.test(value)
|| value.trim() !== value
return needsQuotes ? JSON.stringify(value) : value
}
62 changes: 62 additions & 0 deletions packages/nuxt-cli/test/unit/utils/catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,68 @@ describe('updateCatalogEntries', () => {
expect(readCatalogConfig(tempDir)?.catalogs.default).toEqual({ nuxt: '^4.2.0' })
})

it('should keep an anchor when updating the entry that defines it', async () => {
const filePath = join(tempDir, 'pnpm-workspace.yaml')
await writeFile(filePath, 'catalogs:\n prod:\n nuxt: &nuxt ^4.1.0\n legacy:\n nuxt: *nuxt\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'prod', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated')
expect(await readFile(filePath, 'utf-8')).toBe('catalogs:\n prod:\n nuxt: &nuxt ^4.2.0\n legacy:\n nuxt: *nuxt\n')
})

it('should fail rather than retarget an anchor through one of its aliases', async () => {
await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalogs:\n prod:\n nuxt: &nuxt ^4.1.0\n legacy:\n nuxt: *nuxt\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'legacy', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('failed')
})

it('should add a missing entry to an existing catalog', async () => {
const filePath = join(tempDir, 'pnpm-workspace.yaml')
await writeFile(filePath, 'packages:\n - packages/*\ncatalog:\n vue: ^3.6.0 # pinned\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: '@nuxt/kit', specifier: '^4.2.0' }])).toBe('updated')
expect(await readFile(filePath, 'utf-8')).toBe('packages:\n - packages/*\ncatalog:\n vue: ^3.6.0 # pinned\n "@nuxt/kit": ^4.2.0\n')
})

it('should create the catalog blocks when the workspace has none', async () => {
const filePath = join(tempDir, 'pnpm-workspace.yaml')
await writeFile(filePath, 'packages:\n - packages/*\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated')
expect(updateCatalogEntries(tempDir, [{ catalog: 'prod', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated')
expect(await readFile(filePath, 'utf-8')).toBe('packages:\n - packages/*\ncatalog:\n nuxt: ^4.2.0\ncatalogs:\n prod:\n nuxt: ^4.2.0\n')
})

it('should add a named catalog alongside existing ones', async () => {
const filePath = join(tempDir, 'pnpm-workspace.yaml')
await writeFile(filePath, 'catalogs:\n dev:\n typescript: ^5.9.0\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'prod', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated')
expect(await readFile(filePath, 'utf-8')).toBe('catalogs:\n dev:\n typescript: ^5.9.0\n prod:\n nuxt: ^4.2.0\n')
})

it('should replace a quoted specifier and keep its trailing comment', async () => {
const filePath = join(tempDir, 'pnpm-workspace.yaml')
await writeFile(filePath, 'catalog:\n nuxt: "^4.1.0" # keep me\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.1.0' }])).toBe('unchanged')
expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: 'npm:nuxt-nightly@latest' }])).toBe('updated')
expect(await readFile(filePath, 'utf-8')).toBe('catalog:\n nuxt: npm:nuxt-nightly@latest # keep me\n')
})

it('should ignore a nested key that shares the catalog name', async () => {
const filePath = join(tempDir, 'pnpm-workspace.yaml')
await writeFile(filePath, 'overrides:\n catalog:\n nuxt: ^1.0.0\ncatalog:\n nuxt: ^4.1.0\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('updated')
expect(await readFile(filePath, 'utf-8')).toBe('overrides:\n catalog:\n nuxt: ^1.0.0\ncatalog:\n nuxt: ^4.2.0\n')
})

it('should fail on a flow mapping it cannot edit line by line', async () => {
await writeFile(join(tempDir, 'pnpm-workspace.yaml'), 'catalog: { nuxt: ^4.1.0 }\n')

expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('failed')
})

it('should fail when there is no workspace file', () => {
expect(updateCatalogEntries(tempDir, [{ catalog: 'default', pkg: 'nuxt', specifier: '^4.2.0' }])).toBe('failed')
})
Expand Down
Loading
Loading