From 90a98220563227e88b020edb1c097c13281d8607 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 17 Feb 2026 21:23:00 -0600 Subject: [PATCH 1/7] fix(upgrade): improve CLI usability for monorepos and CI environments - Traverse parent directories for lockfiles and packageManager field in package.json - Add -w flag for pnpm install/remove at workspace roots - Resolve catalog: protocol versions from pnpm-workspace.yaml - Show actionable example commands in non-interactive error messages - Pass verbose: 0 to jscodeshift for error-level file path logging --- .../nextjs-catalog-resolved/package.json | 9 ++ .../nextjs-catalog-resolved/pnpm-lock.yaml | 4 + .../pnpm-workspace.yaml | 7 + .../nextjs-catalog-resolved/src/app.tsx | 9 ++ .../src/__tests__/integration/cli.test.js | 9 ++ .../__tests__/integration/detect-sdk.test.js | 135 +++++++++++++++++- packages/upgrade/src/cli.js | 12 +- packages/upgrade/src/codemods/index.js | 2 + packages/upgrade/src/render.js | 10 +- packages/upgrade/src/runner.js | 6 +- packages/upgrade/src/util/detect-sdk.js | 35 +++++ packages/upgrade/src/util/package-manager.js | 60 +++++--- 12 files changed, 268 insertions(+), 30 deletions(-) create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-lock.yaml create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-workspace.yaml create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/src/app.tsx diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/package.json b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/package.json new file mode 100644 index 00000000000..653e8d6d93b --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-nextjs-catalog-resolved", + "version": "1.0.0", + "dependencies": { + "@clerk/nextjs": "catalog:", + "next": "^14.0.0", + "react": "^18.0.0" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-lock.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-lock.yaml new file mode 100644 index 00000000000..085ee1031f5 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-lock.yaml @@ -0,0 +1,4 @@ +lockfileVersion: '9.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-workspace.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-workspace.yaml new file mode 100644 index 00000000000..50b5f10c5b7 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - 'packages/*' + +catalog: + '@clerk/nextjs': ^6.0.0 + next: ^14.0.0 + react: ^18.0.0 diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/src/app.tsx b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/src/app.tsx new file mode 100644 index 00000000000..19d46c9c6b0 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog-resolved/src/app.tsx @@ -0,0 +1,9 @@ +import { ClerkProvider } from '@clerk/nextjs'; + +export default function App() { + return ( + +
Hello
+
+ ); +} diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index c2ffb8cd8d7..c08c90197e1 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -116,6 +116,15 @@ describe('CLI Integration', () => { expect(output).toContain('--sdk'); expect(result.exitCode).toBe(1); }); + + it('shows example command in non-interactive SDK detection error', async () => { + const dir = getFixturePath('no-clerk'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 5000 }); + + const output = result.stdout + result.stderr; + expect(output).toContain('Example:'); + expect(output).toContain('npx @clerk/upgrade --sdk='); + }); }); describe('--sdk flag', () => { diff --git a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js index 356606ef9ee..ff29e64e7b9 100644 --- a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js +++ b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; -import { detectSdk, getMajorVersion, getSdkVersion, normalizeSdkName } from '../../util/detect-sdk.js'; -import { detectPackageManager } from '../../util/package-manager.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { detectSdk, getMajorVersion, getSdkVersion, normalizeSdkName, resolveCatalogVersion } from '../../util/detect-sdk.js'; +import { detectPackageManager, getInstallCommand, getUninstallCommand } from '../../util/package-manager.js'; import { getFixturePath } from '../helpers/create-fixture.js'; describe('detectSdk', () => { @@ -57,10 +61,15 @@ describe('getSdkVersion', () => { expect(version).toBeNull(); }); - it('returns null for catalog: protocol versions', () => { + it('returns null for catalog: protocol versions without pnpm-workspace.yaml', () => { const version = getSdkVersion('nextjs', getFixturePath('nextjs-catalog')); expect(version).toBeNull(); }); + + it('resolves catalog: protocol versions from pnpm-workspace.yaml', () => { + const version = getSdkVersion('nextjs', getFixturePath('nextjs-catalog-resolved')); + expect(version).toBe(6); + }); }); describe('getMajorVersion', () => { @@ -135,8 +144,120 @@ describe('detectPackageManager', () => { expect(pm).toBe('npm'); }); - it('defaults to npm when no lock file exists', () => { - const pm = detectPackageManager(getFixturePath('no-clerk')); - expect(pm).toBe('npm'); + it('defaults to npm when no lock file exists in any parent', () => { + // Create a temp dir outside the monorepo so no parent lockfile is found + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-pm-test-')); + try { + const pm = detectPackageManager(tmpDir); + expect(pm).toBe('npm'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + describe('parent directory traversal', () => { + let tmpRoot; + let childDir; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-pm-traversal-')); + childDir = path.join(tmpRoot, 'packages', 'web'); + fs.mkdirSync(childDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('finds pnpm-lock.yaml in parent directory', () => { + fs.writeFileSync(path.join(tmpRoot, 'pnpm-lock.yaml'), ''); + expect(detectPackageManager(childDir)).toBe('pnpm'); + }); + + it('finds yarn.lock in parent directory', () => { + fs.writeFileSync(path.join(tmpRoot, 'yarn.lock'), ''); + expect(detectPackageManager(childDir)).toBe('yarn'); + }); + + it('finds packageManager field in parent package.json', () => { + fs.writeFileSync( + path.join(tmpRoot, 'package.json'), + JSON.stringify({ packageManager: 'pnpm@9.0.0' }), + ); + expect(detectPackageManager(childDir)).toBe('pnpm'); + }); + + it('prefers lockfile over packageManager field at same level', () => { + fs.writeFileSync(path.join(tmpRoot, 'yarn.lock'), ''); + fs.writeFileSync( + path.join(tmpRoot, 'package.json'), + JSON.stringify({ packageManager: 'pnpm@9.0.0' }), + ); + expect(detectPackageManager(childDir)).toBe('yarn'); + }); + }); +}); + +describe('resolveCatalogVersion', () => { + it('resolves version from pnpm-workspace.yaml in same directory', () => { + const version = resolveCatalogVersion('@clerk/nextjs', getFixturePath('nextjs-catalog-resolved')); + expect(version).toBe('^6.0.0'); + }); + + it('returns null when pnpm-workspace.yaml does not exist', () => { + const version = resolveCatalogVersion('@clerk/nextjs', getFixturePath('nextjs-catalog')); + expect(version).toBeNull(); + }); + + it('returns null when package is not in catalog', () => { + const version = resolveCatalogVersion('@clerk/unknown-pkg', getFixturePath('nextjs-catalog-resolved')); + expect(version).toBeNull(); + }); + + it('traverses parent directories to find pnpm-workspace.yaml', () => { + // The nextjs-catalog-resolved fixture has pnpm-workspace.yaml at root + // Searching from its src/ subdirectory should still find it + const srcDir = path.join(getFixturePath('nextjs-catalog-resolved'), 'src'); + const version = resolveCatalogVersion('@clerk/nextjs', srcDir); + expect(version).toBe('^6.0.0'); + }); +}); + +describe('getInstallCommand', () => { + it('adds -w flag for pnpm at workspace root', () => { + const dir = getFixturePath('nextjs-catalog-resolved'); + const [cmd, args] = getInstallCommand('pnpm', '@clerk/nextjs', '7.0.0', dir); + expect(cmd).toBe('pnpm'); + expect(args).toContain('-w'); + }); + + it('does not add -w flag for pnpm without pnpm-workspace.yaml', () => { + const dir = getFixturePath('nextjs-v6'); + const [cmd, args] = getInstallCommand('pnpm', '@clerk/nextjs', '7.0.0', dir); + expect(cmd).toBe('pnpm'); + expect(args).not.toContain('-w'); + }); + + it('does not add -w flag for non-pnpm managers', () => { + const dir = getFixturePath('nextjs-catalog-resolved'); + const [cmd, args] = getInstallCommand('npm', '@clerk/nextjs', '7.0.0', dir); + expect(cmd).toBe('npm'); + expect(args).not.toContain('-w'); + }); +}); + +describe('getUninstallCommand', () => { + it('adds -w flag for pnpm at workspace root', () => { + const dir = getFixturePath('nextjs-catalog-resolved'); + const [cmd, args] = getUninstallCommand('pnpm', '@clerk/nextjs', dir); + expect(cmd).toBe('pnpm'); + expect(args).toContain('-w'); + }); + + it('does not add -w flag for pnpm without pnpm-workspace.yaml', () => { + const dir = getFixturePath('nextjs-v6'); + const [cmd, args] = getUninstallCommand('pnpm', '@clerk/nextjs', dir); + expect(cmd).toBe('pnpm'); + expect(args).not.toContain('-w'); }); }); diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index dfcb003e130..f6418103e99 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -49,8 +49,12 @@ const cli = meow( $ npx @clerk/upgrade --canary $ npx @clerk/upgrade --dry-run - Non-interactive mode: + Non-interactive mode (CI): When running in CI or piped environments, --sdk is required if it cannot be auto-detected. + If your version cannot be resolved (e.g. catalog: protocol), also provide --release. + + Example: + $ npx @clerk/upgrade --sdk=nextjs --release=core-3 --dir=./packages/web `, { importMeta: import.meta, @@ -103,6 +107,8 @@ async function main() { .map(s => s.value) .join(', '), ); + renderText(''); + renderText('Example: npx @clerk/upgrade --sdk=nextjs --dir=./packages/web'); process.exit(1); } @@ -140,8 +146,10 @@ async function main() { renderNewline(); if (!isInteractive) { - renderError('Please provide --release flag in non-interactive mode.'); + renderError('Could not detect version. Please provide --release flag in non-interactive mode.'); renderText('Available releases: ' + availableReleases.join(', ')); + renderText(''); + renderText(`Example: npx @clerk/upgrade --sdk=${sdk} --release=${availableReleases[0]}`); process.exit(1); } diff --git a/packages/upgrade/src/codemods/index.js b/packages/upgrade/src/codemods/index.js index cf8c6c8a97a..152557a116e 100644 --- a/packages/upgrade/src/codemods/index.js +++ b/packages/upgrade/src/codemods/index.js @@ -55,6 +55,7 @@ export async function runCodemod(transform = 'transform-async-request', patterns ...options, dry: true, silent: true, + verbose: 0, }); let result = {}; @@ -64,6 +65,7 @@ export async function runCodemod(transform = 'transform-async-request', patterns ...options, dry: false, silent: true, + verbose: 0, }); } diff --git a/packages/upgrade/src/render.js b/packages/upgrade/src/render.js index 5847135a016..923f6f9f74f 100644 --- a/packages/upgrade/src/render.js +++ b/packages/upgrade/src/render.js @@ -192,7 +192,15 @@ export function createSpinner(label) { } export function renderCodemodResults(transform, result) { - console.log(` ${result.ok ?? 0} file(s) modified, ${chalk.red(` ${result.error ?? 0} errors`)}`); + const errorCount = result.error ?? 0; + const okCount = result.ok ?? 0; + + if (errorCount > 0) { + console.log(` ${okCount} file(s) modified, ${chalk.red(`${errorCount} error(s)`)}`); + console.log(chalk.gray(' Error details for failed files are printed above by jscodeshift.')); + } else { + console.log(` ${okCount} file(s) modified`); + } console.log(''); } diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js index 43b3a5f8408..f5767bff574 100644 --- a/packages/upgrade/src/runner.js +++ b/packages/upgrade/src/runner.js @@ -49,7 +49,11 @@ export async function runCodemods(config, sdk, options) { try { const result = await runCodemod(transform, patterns, options); - spinner.success(`Codemod applied: ${chalk.dim(transform)}`); + if (result.error > 0) { + spinner.error(`Codemod applied with errors: ${chalk.dim(transform)}`); + } else { + spinner.success(`Codemod applied: ${chalk.dim(transform)}`); + } renderCodemodResults(transform, result); const codemodConfig = getCodemodConfig(transform); diff --git a/packages/upgrade/src/util/detect-sdk.js b/packages/upgrade/src/util/detect-sdk.js index 4f8384d0107..5b40291eca1 100644 --- a/packages/upgrade/src/util/detect-sdk.js +++ b/packages/upgrade/src/util/detect-sdk.js @@ -49,6 +49,33 @@ export function detectSdk(dir) { return null; } +export function resolveCatalogVersion(packageName, dir) { + let current = path.resolve(dir); + const root = path.parse(current).root; + + while (current !== root) { + const wsPath = path.join(current, 'pnpm-workspace.yaml'); + if (fs.existsSync(wsPath)) { + try { + const content = fs.readFileSync(wsPath, 'utf8'); + // Match both catalog.default and catalog. sections + // Format: `'packageName': version` or `packageName: version` under catalog(s) sections + const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`^\\s*['"]?${escapedName}['"]?\\s*:\\s*['"]?([^'"\\s#]+)['"]?`, 'm'); + const match = content.match(pattern); + if (match) { + return match[1]; + } + } catch { + /* continue */ + } + } + current = path.dirname(current); + } + + return null; +} + export function getSdkVersion(sdk, dir) { let pkg; try { @@ -70,6 +97,14 @@ export function getSdkVersion(sdk, dir) { return null; } + if (version.startsWith('catalog:')) { + const resolvedVersion = resolveCatalogVersion(pkgName, dir) || (oldPkgName && resolveCatalogVersion(oldPkgName, dir)); + if (resolvedVersion) { + return getMajorVersion(resolvedVersion); + } + return null; + } + return getMajorVersion(version); } diff --git a/packages/upgrade/src/util/package-manager.js b/packages/upgrade/src/util/package-manager.js index 266b24d7063..c0692179083 100644 --- a/packages/upgrade/src/util/package-manager.js +++ b/packages/upgrade/src/util/package-manager.js @@ -3,28 +3,47 @@ import fs from 'node:fs'; import path from 'node:path'; export function detectPackageManager(dir) { - if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) { - return 'pnpm'; - } - if (fs.existsSync(path.join(dir, 'yarn.lock'))) { - return 'yarn'; - } - if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) { - return 'bun'; - } - if (fs.existsSync(path.join(dir, 'package-lock.json'))) { - return 'npm'; + let current = path.resolve(dir); + const root = path.parse(current).root; + + while (current !== root) { + if (fs.existsSync(path.join(current, 'pnpm-lock.yaml'))) return 'pnpm'; + if (fs.existsSync(path.join(current, 'yarn.lock'))) return 'yarn'; + if (fs.existsSync(path.join(current, 'bun.lockb')) || fs.existsSync(path.join(current, 'bun.lock'))) return 'bun'; + if (fs.existsSync(path.join(current, 'package-lock.json'))) return 'npm'; + + try { + const pkgPath = path.join(current, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.packageManager) { + const pmName = pkg.packageManager.split('@')[0]; + if (['pnpm', 'yarn', 'bun', 'npm'].includes(pmName)) return pmName; + } + } + } catch { + /* continue */ + } + + current = path.dirname(current); } return 'npm'; } -export function getInstallCommand(packageManager, packageName, version = 'latest') { +export function isPnpmWorkspaceRoot(dir) { + return fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')); +} + +export function getInstallCommand(packageManager, packageName, version = 'latest', cwd) { const pkg = version === 'latest' ? packageName : `${packageName}@${version}`; switch (packageManager) { - case 'pnpm': - return ['pnpm', ['add', pkg]]; + case 'pnpm': { + const args = ['add', pkg]; + if (cwd && isPnpmWorkspaceRoot(cwd)) args.push('-w'); + return ['pnpm', args]; + } case 'yarn': return ['yarn', ['add', pkg]]; case 'bun': @@ -35,10 +54,13 @@ export function getInstallCommand(packageManager, packageName, version = 'latest } } -export function getUninstallCommand(packageManager, packageName) { +export function getUninstallCommand(packageManager, packageName, cwd) { switch (packageManager) { - case 'pnpm': - return ['pnpm', ['remove', packageName]]; + case 'pnpm': { + const args = ['remove', packageName]; + if (cwd && isPnpmWorkspaceRoot(cwd)) args.push('-w'); + return ['pnpm', args]; + } case 'yarn': return ['yarn', ['remove', packageName]]; case 'bun': @@ -81,12 +103,12 @@ export async function runPackageManagerCommand(command, args, cwd) { } export async function upgradePackage(packageManager, packageName, version, cwd) { - const [cmd, args] = getInstallCommand(packageManager, packageName, version); + const [cmd, args] = getInstallCommand(packageManager, packageName, version, cwd); return runPackageManagerCommand(cmd, args, cwd); } export async function removePackage(packageManager, packageName, cwd) { - const [cmd, args] = getUninstallCommand(packageManager, packageName); + const [cmd, args] = getUninstallCommand(packageManager, packageName, cwd); return runPackageManagerCommand(cmd, args, cwd); } From 0535171fd7bb8986c40d99eeb15f3fb1b302e7e1 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 19 Feb 2026 15:05:39 -0600 Subject: [PATCH 2/7] fix(upgrade): fix formatting and lint errors --- .../__tests__/integration/detect-sdk.test.js | 18 ++++++------ packages/upgrade/src/util/detect-sdk.js | 3 +- packages/upgrade/src/util/package-manager.js | 28 ++++++++++++++----- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js index ff29e64e7b9..cbf732d43d7 100644 --- a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js +++ b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js @@ -4,7 +4,13 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { detectSdk, getMajorVersion, getSdkVersion, normalizeSdkName, resolveCatalogVersion } from '../../util/detect-sdk.js'; +import { + detectSdk, + getMajorVersion, + getSdkVersion, + normalizeSdkName, + resolveCatalogVersion, +} from '../../util/detect-sdk.js'; import { detectPackageManager, getInstallCommand, getUninstallCommand } from '../../util/package-manager.js'; import { getFixturePath } from '../helpers/create-fixture.js'; @@ -180,19 +186,13 @@ describe('detectPackageManager', () => { }); it('finds packageManager field in parent package.json', () => { - fs.writeFileSync( - path.join(tmpRoot, 'package.json'), - JSON.stringify({ packageManager: 'pnpm@9.0.0' }), - ); + fs.writeFileSync(path.join(tmpRoot, 'package.json'), JSON.stringify({ packageManager: 'pnpm@9.0.0' })); expect(detectPackageManager(childDir)).toBe('pnpm'); }); it('prefers lockfile over packageManager field at same level', () => { fs.writeFileSync(path.join(tmpRoot, 'yarn.lock'), ''); - fs.writeFileSync( - path.join(tmpRoot, 'package.json'), - JSON.stringify({ packageManager: 'pnpm@9.0.0' }), - ); + fs.writeFileSync(path.join(tmpRoot, 'package.json'), JSON.stringify({ packageManager: 'pnpm@9.0.0' })); expect(detectPackageManager(childDir)).toBe('yarn'); }); }); diff --git a/packages/upgrade/src/util/detect-sdk.js b/packages/upgrade/src/util/detect-sdk.js index 5b40291eca1..5c17392259b 100644 --- a/packages/upgrade/src/util/detect-sdk.js +++ b/packages/upgrade/src/util/detect-sdk.js @@ -98,7 +98,8 @@ export function getSdkVersion(sdk, dir) { } if (version.startsWith('catalog:')) { - const resolvedVersion = resolveCatalogVersion(pkgName, dir) || (oldPkgName && resolveCatalogVersion(oldPkgName, dir)); + const resolvedVersion = + resolveCatalogVersion(pkgName, dir) || (oldPkgName && resolveCatalogVersion(oldPkgName, dir)); if (resolvedVersion) { return getMajorVersion(resolvedVersion); } diff --git a/packages/upgrade/src/util/package-manager.js b/packages/upgrade/src/util/package-manager.js index c0692179083..b9dcb774e75 100644 --- a/packages/upgrade/src/util/package-manager.js +++ b/packages/upgrade/src/util/package-manager.js @@ -7,10 +7,18 @@ export function detectPackageManager(dir) { const root = path.parse(current).root; while (current !== root) { - if (fs.existsSync(path.join(current, 'pnpm-lock.yaml'))) return 'pnpm'; - if (fs.existsSync(path.join(current, 'yarn.lock'))) return 'yarn'; - if (fs.existsSync(path.join(current, 'bun.lockb')) || fs.existsSync(path.join(current, 'bun.lock'))) return 'bun'; - if (fs.existsSync(path.join(current, 'package-lock.json'))) return 'npm'; + if (fs.existsSync(path.join(current, 'pnpm-lock.yaml'))) { + return 'pnpm'; + } + if (fs.existsSync(path.join(current, 'yarn.lock'))) { + return 'yarn'; + } + if (fs.existsSync(path.join(current, 'bun.lockb')) || fs.existsSync(path.join(current, 'bun.lock'))) { + return 'bun'; + } + if (fs.existsSync(path.join(current, 'package-lock.json'))) { + return 'npm'; + } try { const pkgPath = path.join(current, 'package.json'); @@ -18,7 +26,9 @@ export function detectPackageManager(dir) { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); if (pkg.packageManager) { const pmName = pkg.packageManager.split('@')[0]; - if (['pnpm', 'yarn', 'bun', 'npm'].includes(pmName)) return pmName; + if (['pnpm', 'yarn', 'bun', 'npm'].includes(pmName)) { + return pmName; + } } } } catch { @@ -41,7 +51,9 @@ export function getInstallCommand(packageManager, packageName, version = 'latest switch (packageManager) { case 'pnpm': { const args = ['add', pkg]; - if (cwd && isPnpmWorkspaceRoot(cwd)) args.push('-w'); + if (cwd && isPnpmWorkspaceRoot(cwd)) { + args.push('-w'); + } return ['pnpm', args]; } case 'yarn': @@ -58,7 +70,9 @@ export function getUninstallCommand(packageManager, packageName, cwd) { switch (packageManager) { case 'pnpm': { const args = ['remove', packageName]; - if (cwd && isPnpmWorkspaceRoot(cwd)) args.push('-w'); + if (cwd && isPnpmWorkspaceRoot(cwd)) { + args.push('-w'); + } return ['pnpm', args]; } case 'yarn': From 7a27d3a3c78faf0dbba4e82c630ccf343425bfd6 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 19 Feb 2026 19:22:05 -0600 Subject: [PATCH 3/7] fix(upgrade): traverse parent dirs for workspace detection, support named catalogs --- .../nextjs-named-catalog/package.json | 9 ++ .../nextjs-named-catalog/pnpm-lock.yaml | 1 + .../nextjs-named-catalog/pnpm-workspace.yaml | 12 +++ .../__tests__/integration/detect-sdk.test.js | 88 +++++++++++++++++-- packages/upgrade/src/util/detect-sdk.js | 59 +++++++++++-- packages/upgrade/src/util/package-manager.js | 18 +++- 6 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-lock.yaml create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-workspace.yaml diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/package.json b/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/package.json new file mode 100644 index 00000000000..15bc9d3c829 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-nextjs-named-catalog", + "version": "1.0.0", + "dependencies": { + "@clerk/nextjs": "catalog:clerk", + "next": "^14.0.0", + "react": "catalog:peer-react" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-lock.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-lock.yaml new file mode 100644 index 00000000000..b0a073a9c13 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-workspace.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-workspace.yaml new file mode 100644 index 00000000000..69cb4b80d93 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-named-catalog/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - 'packages/*' + +catalogs: + clerk: + '@clerk/nextjs': ^7.0.0 + peer-react: + react: ^18.0.0 || ~19.0.3 + react-dom: ^18.0.0 || ~19.0.3 + react: + react: 18.3.1 + react-dom: 18.3.1 diff --git a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js index cbf732d43d7..523f543eaab 100644 --- a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js +++ b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js @@ -11,7 +11,12 @@ import { normalizeSdkName, resolveCatalogVersion, } from '../../util/detect-sdk.js'; -import { detectPackageManager, getInstallCommand, getUninstallCommand } from '../../util/package-manager.js'; +import { + detectPackageManager, + getInstallCommand, + getUninstallCommand, + isInPnpmWorkspace, +} from '../../util/package-manager.js'; import { getFixturePath } from '../helpers/create-fixture.js'; describe('detectSdk', () => { @@ -76,6 +81,11 @@ describe('getSdkVersion', () => { const version = getSdkVersion('nextjs', getFixturePath('nextjs-catalog-resolved')); expect(version).toBe(6); }); + + it('resolves named catalog: protocol versions from pnpm-workspace.yaml', () => { + const version = getSdkVersion('nextjs', getFixturePath('nextjs-named-catalog')); + expect(version).toBe(7); + }); }); describe('getMajorVersion', () => { @@ -221,6 +231,44 @@ describe('resolveCatalogVersion', () => { const version = resolveCatalogVersion('@clerk/nextjs', srcDir); expect(version).toBe('^6.0.0'); }); + + it('resolves version from a named catalog section', () => { + const version = resolveCatalogVersion('@clerk/nextjs', getFixturePath('nextjs-named-catalog'), 'clerk'); + expect(version).toBe('^7.0.0'); + }); + + it('resolves correct version when package exists in multiple named catalogs', () => { + const peerVersion = resolveCatalogVersion('react', getFixturePath('nextjs-named-catalog'), 'peer-react'); + expect(peerVersion).toBe('^18.0.0'); + + const pinnedVersion = resolveCatalogVersion('react', getFixturePath('nextjs-named-catalog'), 'react'); + expect(pinnedVersion).toBe('18.3.1'); + }); + + it('returns null when named catalog does not exist', () => { + const version = resolveCatalogVersion('@clerk/nextjs', getFixturePath('nextjs-named-catalog'), 'nonexistent'); + expect(version).toBeNull(); + }); +}); + +describe('isInPnpmWorkspace', () => { + it('returns true when pnpm-workspace.yaml exists in current directory', () => { + expect(isInPnpmWorkspace(getFixturePath('nextjs-catalog-resolved'))).toBe(true); + }); + + it('returns true when pnpm-workspace.yaml exists in parent directory', () => { + const srcDir = path.join(getFixturePath('nextjs-catalog-resolved'), 'src'); + expect(isInPnpmWorkspace(srcDir)).toBe(true); + }); + + it('returns false when no pnpm-workspace.yaml exists in any parent', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-ws-test-')); + try { + expect(isInPnpmWorkspace(tmpDir)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe('getInstallCommand', () => { @@ -231,11 +279,22 @@ describe('getInstallCommand', () => { expect(args).toContain('-w'); }); - it('does not add -w flag for pnpm without pnpm-workspace.yaml', () => { - const dir = getFixturePath('nextjs-v6'); - const [cmd, args] = getInstallCommand('pnpm', '@clerk/nextjs', '7.0.0', dir); + it('adds -w flag for pnpm from a workspace subdirectory', () => { + const srcDir = path.join(getFixturePath('nextjs-catalog-resolved'), 'src'); + const [cmd, args] = getInstallCommand('pnpm', '@clerk/nextjs', '7.0.0', srcDir); expect(cmd).toBe('pnpm'); - expect(args).not.toContain('-w'); + expect(args).toContain('-w'); + }); + + it('does not add -w flag for pnpm outside a workspace', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-no-ws-')); + try { + const [cmd, args] = getInstallCommand('pnpm', '@clerk/nextjs', '7.0.0', tmpDir); + expect(cmd).toBe('pnpm'); + expect(args).not.toContain('-w'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); it('does not add -w flag for non-pnpm managers', () => { @@ -254,10 +313,21 @@ describe('getUninstallCommand', () => { expect(args).toContain('-w'); }); - it('does not add -w flag for pnpm without pnpm-workspace.yaml', () => { - const dir = getFixturePath('nextjs-v6'); - const [cmd, args] = getUninstallCommand('pnpm', '@clerk/nextjs', dir); + it('adds -w flag for pnpm from a workspace subdirectory', () => { + const srcDir = path.join(getFixturePath('nextjs-catalog-resolved'), 'src'); + const [cmd, args] = getUninstallCommand('pnpm', '@clerk/nextjs', srcDir); expect(cmd).toBe('pnpm'); - expect(args).not.toContain('-w'); + expect(args).toContain('-w'); + }); + + it('does not add -w flag for pnpm outside a workspace', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-no-ws-')); + try { + const [cmd, args] = getUninstallCommand('pnpm', '@clerk/nextjs', tmpDir); + expect(cmd).toBe('pnpm'); + expect(args).not.toContain('-w'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } }); }); diff --git a/packages/upgrade/src/util/detect-sdk.js b/packages/upgrade/src/util/detect-sdk.js index 5c17392259b..852519225d7 100644 --- a/packages/upgrade/src/util/detect-sdk.js +++ b/packages/upgrade/src/util/detect-sdk.js @@ -49,7 +49,7 @@ export function detectSdk(dir) { return null; } -export function resolveCatalogVersion(packageName, dir) { +export function resolveCatalogVersion(packageName, dir, catalogName) { let current = path.resolve(dir); const root = path.parse(current).root; @@ -58,13 +58,14 @@ export function resolveCatalogVersion(packageName, dir) { if (fs.existsSync(wsPath)) { try { const content = fs.readFileSync(wsPath, 'utf8'); - // Match both catalog.default and catalog. sections - // Format: `'packageName': version` or `packageName: version` under catalog(s) sections - const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`^\\s*['"]?${escapedName}['"]?\\s*:\\s*['"]?([^'"\\s#]+)['"]?`, 'm'); - const match = content.match(pattern); - if (match) { - return match[1]; + const section = catalogName ? getNamedCatalogSection(content, catalogName) : getDefaultCatalogSection(content); + if (section) { + const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`^\\s*['"]?${escapedName}['"]?\\s*:\\s*['"]?([^'"\\s#]+)['"]?`, 'm'); + const match = section.match(pattern); + if (match) { + return match[1]; + } } } catch { /* continue */ @@ -76,6 +77,44 @@ export function resolveCatalogVersion(packageName, dir) { return null; } +function getDefaultCatalogSection(content) { + // Match `catalog:` (singular) but not `catalogs:` + const match = content.match(/^catalog:\s*$/m); + if (!match) { + return null; + } + const start = match.index + match[0].length; + // Extract indented lines until the next top-level key + const rest = content.slice(start); + const end = rest.search(/^\S/m); + return end === -1 ? rest : rest.slice(0, end); +} + +function getNamedCatalogSection(content, catalogName) { + // Find the `catalogs:` (plural) section, then the named subsection + const catalogsMatch = content.match(/^catalogs:\s*$/m); + if (!catalogsMatch) { + return null; + } + const catalogsStart = catalogsMatch.index + catalogsMatch[0].length; + const catalogsRest = content.slice(catalogsStart); + // End of catalogs section is the next non-indented line + const catalogsEnd = catalogsRest.search(/^\S/m); + const catalogsBody = catalogsEnd === -1 ? catalogsRest : catalogsRest.slice(0, catalogsEnd); + + // Find the named catalog subsection (2-space indented key) + const escapedCatalogName = catalogName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const nameMatch = catalogsBody.match(new RegExp(`^ ${escapedCatalogName}:\\s*$`, 'm')); + if (!nameMatch) { + return null; + } + const nameStart = nameMatch.index + nameMatch[0].length; + const nameRest = catalogsBody.slice(nameStart); + // End of this named section is the next line at 2-space indent level (sibling catalog) or less + const nameEnd = nameRest.search(/^ {2}\S/m); + return nameEnd === -1 ? nameRest : nameRest.slice(0, nameEnd); +} + export function getSdkVersion(sdk, dir) { let pkg; try { @@ -98,8 +137,10 @@ export function getSdkVersion(sdk, dir) { } if (version.startsWith('catalog:')) { + const catalogName = version.slice('catalog:'.length) || undefined; const resolvedVersion = - resolveCatalogVersion(pkgName, dir) || (oldPkgName && resolveCatalogVersion(oldPkgName, dir)); + resolveCatalogVersion(pkgName, dir, catalogName) || + (oldPkgName && resolveCatalogVersion(oldPkgName, dir, catalogName)); if (resolvedVersion) { return getMajorVersion(resolvedVersion); } diff --git a/packages/upgrade/src/util/package-manager.js b/packages/upgrade/src/util/package-manager.js index b9dcb774e75..adeeebbf911 100644 --- a/packages/upgrade/src/util/package-manager.js +++ b/packages/upgrade/src/util/package-manager.js @@ -41,8 +41,18 @@ export function detectPackageManager(dir) { return 'npm'; } -export function isPnpmWorkspaceRoot(dir) { - return fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')); +export function isInPnpmWorkspace(dir) { + let current = path.resolve(dir); + const root = path.parse(current).root; + + while (current !== root) { + if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return true; + } + current = path.dirname(current); + } + + return false; } export function getInstallCommand(packageManager, packageName, version = 'latest', cwd) { @@ -51,7 +61,7 @@ export function getInstallCommand(packageManager, packageName, version = 'latest switch (packageManager) { case 'pnpm': { const args = ['add', pkg]; - if (cwd && isPnpmWorkspaceRoot(cwd)) { + if (cwd && isInPnpmWorkspace(cwd)) { args.push('-w'); } return ['pnpm', args]; @@ -70,7 +80,7 @@ export function getUninstallCommand(packageManager, packageName, cwd) { switch (packageManager) { case 'pnpm': { const args = ['remove', packageName]; - if (cwd && isPnpmWorkspaceRoot(cwd)) { + if (cwd && isInPnpmWorkspace(cwd)) { args.push('-w'); } return ['pnpm', args]; From 64bbdf6eba7ee1fa2b0c875f7f3183da22e2dea5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 19 Feb 2026 19:27:47 -0600 Subject: [PATCH 4/7] chore: add changeset --- .changeset/fix-upgrade-cli-usability.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-upgrade-cli-usability.md diff --git a/.changeset/fix-upgrade-cli-usability.md b/.changeset/fix-upgrade-cli-usability.md new file mode 100644 index 00000000000..778a1d2e216 --- /dev/null +++ b/.changeset/fix-upgrade-cli-usability.md @@ -0,0 +1,5 @@ +--- +'@clerk/upgrade': patch +--- + +Improve CLI usability for monorepos: traverse parent directories for pnpm workspace detection and support named catalogs in version resolution From ce11b960064b4263e4ee6a5e5864d0450f87c7f0 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 20 Feb 2026 11:04:08 -0600 Subject: [PATCH 5/7] fix(upgrade): exclude .md and .tsbuildinfo from scan ignores --- packages/upgrade/src/runner.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js index f5767bff574..a693ccd02c2 100644 --- a/packages/upgrade/src/runner.js +++ b/packages/upgrade/src/runner.js @@ -24,6 +24,8 @@ const GLOBBY_IGNORE = [ '**/yarn.lock', 'pnpm-lock.yaml', '**/pnpm-lock.yaml', + '**/*.md', + '**/*.tsbuildinfo', '**/*.{png,webp,svg,gif,jpg,jpeg}', '**/*.{mp4,mkv,wmv,m4v,mov,avi,flv,webm,flac,mka,m4a,aac,ogg}', ]; From 5e79db77e2035abf790ede126850ba149fd14d16 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 20 Feb 2026 11:24:10 -0600 Subject: [PATCH 6/7] fix(upgrade): resolve SDK version from workspace packages when not in root --- .../apps/web/package.json | 9 +++ .../monorepo-workspace-catalog/package.json | 8 ++ .../monorepo-workspace-catalog/pnpm-lock.yaml | 1 + .../pnpm-workspace.yaml | 7 ++ .../__tests__/integration/detect-sdk.test.js | 54 +++++++++++++ packages/upgrade/src/cli.js | 10 ++- packages/upgrade/src/util/detect-sdk.js | 79 +++++++++++++++++++ 7 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/apps/web/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-lock.yaml create mode 100644 packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-workspace.yaml diff --git a/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/apps/web/package.json b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/apps/web/package.json new file mode 100644 index 00000000000..ad57108f8cb --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/apps/web/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-web-app", + "version": "1.0.0", + "dependencies": { + "@clerk/nextjs": "catalog:", + "next": "catalog:", + "react": "catalog:" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/package.json b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/package.json new file mode 100644 index 00000000000..47bd2fb683f --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-monorepo-root", + "version": "1.0.0", + "private": true, + "dependencies": { + "@clerk/backend": "^1.0.0" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-lock.yaml b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-lock.yaml new file mode 100644 index 00000000000..b0a073a9c13 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' diff --git a/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-workspace.yaml b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-workspace.yaml new file mode 100644 index 00000000000..7f75d871feb --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/monorepo-workspace-catalog/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - 'apps/*' + +catalog: + '@clerk/nextjs': ^6.0.0 + next: ^14.0.0 + react: ^18.0.0 diff --git a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js index 523f543eaab..a5e2a84541a 100644 --- a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js +++ b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js @@ -6,8 +6,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { detectSdk, + findWorkspaceRoot, getMajorVersion, getSdkVersion, + getSdkVersionFromWorkspaces, + getWorkspacePackageDirs, normalizeSdkName, resolveCatalogVersion, } from '../../util/detect-sdk.js'; @@ -271,6 +274,57 @@ describe('isInPnpmWorkspace', () => { }); }); +describe('findWorkspaceRoot', () => { + it('finds pnpm workspace root from the root directory', () => { + const root = findWorkspaceRoot(getFixturePath('monorepo-workspace-catalog')); + expect(root).toBe(getFixturePath('monorepo-workspace-catalog')); + }); + + it('finds pnpm workspace root from a subdirectory', () => { + const root = findWorkspaceRoot(path.join(getFixturePath('monorepo-workspace-catalog'), 'apps', 'web')); + expect(root).toBe(getFixturePath('monorepo-workspace-catalog')); + }); + + it('returns null when no workspace root exists', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-no-ws-')); + try { + expect(findWorkspaceRoot(tmpDir)).toBeNull(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +describe('getWorkspacePackageDirs', () => { + it('returns workspace package directories from pnpm-workspace.yaml', () => { + const root = getFixturePath('monorepo-workspace-catalog'); + const dirs = getWorkspacePackageDirs(root); + expect(dirs).toHaveLength(1); + expect(dirs[0]).toContain(path.join('apps', 'web')); + }); +}); + +describe('getSdkVersionFromWorkspaces', () => { + it('finds SDK version from a workspace package when not in root', () => { + const version = getSdkVersionFromWorkspaces('nextjs', getFixturePath('monorepo-workspace-catalog')); + expect(version).toBe(6); + }); + + it('returns null when SDK is not in any workspace package', () => { + const version = getSdkVersionFromWorkspaces('expo', getFixturePath('monorepo-workspace-catalog')); + expect(version).toBeNull(); + }); + + it('returns null when not in a workspace', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'clerk-no-ws-')); + try { + expect(getSdkVersionFromWorkspaces('nextjs', tmpDir)).toBeNull(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + describe('getInstallCommand', () => { it('adds -w flag for pnpm at workspace root', () => { const dir = getFixturePath('nextjs-catalog-resolved'); diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index f6418103e99..9c22e51f75f 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -17,7 +17,13 @@ import { renderWarning, } from './render.js'; import { runCodemods, runScans } from './runner.js'; -import { detectSdk, getSdkVersion, getSupportedSdks, normalizeSdkName } from './util/detect-sdk.js'; +import { + detectSdk, + getSdkVersion, + getSdkVersionFromWorkspaces, + getSupportedSdks, + normalizeSdkName, +} from './util/detect-sdk.js'; import { detectPackageManager, getPackageManagerDisplayName, @@ -126,7 +132,7 @@ async function main() { } // Step 2: Get current version and detect package manager - const currentVersion = getSdkVersion(sdk, options.dir); + const currentVersion = getSdkVersion(sdk, options.dir) ?? getSdkVersionFromWorkspaces(sdk, options.dir); const packageManager = detectPackageManager(options.dir); // Step 3: If version couldn't be detected and no release specified, prompt user diff --git a/packages/upgrade/src/util/detect-sdk.js b/packages/upgrade/src/util/detect-sdk.js index 852519225d7..2b2e9ad7f81 100644 --- a/packages/upgrade/src/util/detect-sdk.js +++ b/packages/upgrade/src/util/detect-sdk.js @@ -3,6 +3,7 @@ import path from 'node:path'; import { readPackageSync } from 'read-pkg'; import semverRegex from 'semver-regex'; +import { globSync } from 'tinyglobby'; import { getOldPackageName } from '../config.js'; @@ -181,6 +182,84 @@ export function getInstalledClerkPackages(dir) { ); } +export function findWorkspaceRoot(dir) { + let current = path.resolve(dir); + const root = path.parse(current).root; + + while (current !== root) { + if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) { + return current; + } + try { + const pkgPath = path.join(current, 'package.json'); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.workspaces) { + return current; + } + } + } catch { + /* continue */ + } + current = path.dirname(current); + } + + return null; +} + +export function getWorkspacePackageDirs(workspaceRoot) { + const pnpmWsPath = path.join(workspaceRoot, 'pnpm-workspace.yaml'); + + let packageGlobs = []; + + if (fs.existsSync(pnpmWsPath)) { + const content = fs.readFileSync(pnpmWsPath, 'utf8'); + const match = content.match(/^packages:\s*\n((?:\s+-\s+.+\n?)*)/m); + if (match) { + packageGlobs = match[1] + .split('\n') + .map(line => line.replace(/^\s*-\s+['"]?([^'"]+)['"]?\s*$/, '$1')) + .filter(Boolean); + } + } else { + try { + const pkgPath = path.join(workspaceRoot, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (Array.isArray(pkg.workspaces)) { + packageGlobs = pkg.workspaces; + } else if (pkg.workspaces?.packages) { + packageGlobs = pkg.workspaces.packages; + } + } catch { + return []; + } + } + + if (packageGlobs.length === 0) { + return []; + } + + return globSync(packageGlobs, { cwd: workspaceRoot, onlyDirectories: true, absolute: true }); +} + +export function getSdkVersionFromWorkspaces(sdk, dir) { + const workspaceRoot = findWorkspaceRoot(dir); + if (!workspaceRoot) { + return null; + } + + const packageDirs = getWorkspacePackageDirs(workspaceRoot); + + for (const pkgDir of packageDirs) { + const version = getSdkVersion(sdk, pkgDir); + if (version !== null) { + return version; + } + } + + return null; +} + export function normalizeSdkName(sdk) { if (!sdk) { return null; From 01217a8345433077c3971078898ab72156d07a61 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 20 Feb 2026 11:27:19 -0600 Subject: [PATCH 7/7] fix(upgrade): add workspace-aware error messages with monorepo guidance --- .../src/__tests__/integration/cli.test.js | 4 +- packages/upgrade/src/cli.js | 63 ++++++++++++++----- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index c08c90197e1..99de5048491 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -122,8 +122,8 @@ describe('CLI Integration', () => { const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 5000 }); const output = result.stdout + result.stderr; - expect(output).toContain('Example:'); - expect(output).toContain('npx @clerk/upgrade --sdk='); + expect(output).toContain('npx @clerk/upgrade'); + expect(output).toContain('--sdk'); }); }); diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 9c22e51f75f..c938fe9cc33 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -19,6 +19,7 @@ import { import { runCodemods, runScans } from './runner.js'; import { detectSdk, + findWorkspaceRoot, getSdkVersion, getSdkVersionFromWorkspaces, getSupportedSdks, @@ -105,16 +106,27 @@ async function main() { } if (!sdk) { + const isWorkspace = !!findWorkspaceRoot(options.dir); + if (!isInteractive) { renderError('Could not detect Clerk SDK. Please provide --sdk flag in non-interactive mode.'); - renderText( - 'Supported SDKs: ' + - getSupportedSdks() - .map(s => s.value) - .join(', '), - ); - renderText(''); - renderText('Example: npx @clerk/upgrade --sdk=nextjs --dir=./packages/web'); + if (isWorkspace) { + renderText(''); + renderText('It looks like you are in a monorepo. Try pointing to a specific workspace package:'); + renderText(' npx @clerk/upgrade --dir=./apps/web'); + renderText(''); + renderText('Or specify the SDK directly:'); + renderText(' npx @clerk/upgrade --sdk=nextjs'); + } else { + renderText( + 'Supported SDKs: ' + + getSupportedSdks() + .map(s => s.value) + .join(', '), + ); + renderText(''); + renderText('Example: npx @clerk/upgrade --sdk=nextjs'); + } process.exit(1); } @@ -146,16 +158,26 @@ async function main() { process.exit(1); } + const isWorkspace = !!findWorkspaceRoot(options.dir); + renderWarning( - `Could not detect your @clerk/${sdk} version (you may be using catalog: protocol or a non-standard version specifier).`, + `Could not detect your @clerk/${sdk} version (you may be using workspace:, catalog:, or a non-standard version specifier).`, ); renderNewline(); if (!isInteractive) { - renderError('Could not detect version. Please provide --release flag in non-interactive mode.'); - renderText('Available releases: ' + availableReleases.join(', ')); - renderText(''); - renderText(`Example: npx @clerk/upgrade --sdk=${sdk} --release=${availableReleases[0]}`); + if (isWorkspace) { + renderText('It looks like you are in a monorepo. Try pointing to a specific workspace package:'); + renderText(` npx @clerk/upgrade --dir=./apps/web`); + renderText(''); + renderText('Or specify the release directly:'); + renderText(` npx @clerk/upgrade --sdk=${sdk} --release=${availableReleases[0]}`); + } else { + renderError('Could not detect version. Please provide --release flag in non-interactive mode.'); + renderText('Available releases: ' + availableReleases.join(', ')); + renderText(''); + renderText(`Example: npx @clerk/upgrade --sdk=${sdk} --release=${availableReleases[0]}`); + } process.exit(1); } @@ -178,7 +200,20 @@ async function main() { const config = await loadConfig(sdk, currentVersion, release); if (!config) { - renderError(`No upgrade path found for @clerk/${sdk}. Your version may be too old for this upgrade tool.`); + const isWorkspace = !!findWorkspaceRoot(options.dir); + renderError(`No upgrade path found for @clerk/${sdk}.`); + + if (isWorkspace) { + renderText(''); + renderText('It looks like you are in a monorepo. Try pointing to a specific workspace package:'); + renderText(' npx @clerk/upgrade --dir=./apps/web'); + renderText(''); + renderText('Or specify the SDK and release directly:'); + renderText(` npx @clerk/upgrade --sdk=nextjs --release=${getAvailableReleases()[0] || 'core-3'}`); + } else { + renderText('Your version may be too old for this upgrade tool.'); + } + process.exit(1); }