diff --git a/.changeset/smooth-foxes-jump.md b/.changeset/smooth-foxes-jump.md new file mode 100644 index 00000000000..7eea6259fc6 --- /dev/null +++ b/.changeset/smooth-foxes-jump.md @@ -0,0 +1,6 @@ +--- +'@clerk/upgrade': patch +--- + +Handle `catalog:` protocol and other non-standard version specifiers + diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/package.json b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/package.json new file mode 100644 index 00000000000..24f7253f2b9 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-nextjs-catalog", + "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/pnpm-lock.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/pnpm-lock.yaml new file mode 100644 index 00000000000..876cf5cca99 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/pnpm-lock.yaml @@ -0,0 +1,5 @@ +lockfileVersion: '9.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/src/app.tsx b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/src/app.tsx new file mode 100644 index 00000000000..df659a20472 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-catalog/src/app.tsx @@ -0,0 +1,5 @@ +import { SignIn } from '@clerk/nextjs'; + +export default function Home() { + return ; +} diff --git a/packages/upgrade/src/__tests__/integration/config.test.js b/packages/upgrade/src/__tests__/integration/config.test.js index 79a56976f2c..21c46f9a594 100644 --- a/packages/upgrade/src/__tests__/integration/config.test.js +++ b/packages/upgrade/src/__tests__/integration/config.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getOldPackageName, getTargetPackageName, loadConfig } from '../../config.js'; +import { getAvailableReleases, getOldPackageName, getTargetPackageName, loadConfig } from '../../config.js'; describe('loadConfig', () => { it('returns config with needsUpgrade: true for nextjs v6', async () => { @@ -128,3 +128,46 @@ describe('getOldPackageName', () => { expect(getOldPackageName('nextjs')).toBeNull(); }); }); + +describe('getAvailableReleases', () => { + it('returns an array of available releases', () => { + const releases = getAvailableReleases(); + + expect(Array.isArray(releases)).toBe(true); + expect(releases.length).toBeGreaterThan(0); + }); + + it('includes core-3 release', () => { + const releases = getAvailableReleases(); + + expect(releases).toContain('core-3'); + }); + + it('includes core-2 release', () => { + const releases = getAvailableReleases(); + + expect(releases).toContain('core-2'); + }); + + it('returns releases in reverse order (newest first)', () => { + const releases = getAvailableReleases(); + + expect(releases[0]).toBe('core-3'); + expect(releases[1]).toBe('core-2'); + }); +}); + +describe('loadConfig with null version', () => { + it('returns config when release is explicitly provided', async () => { + const config = await loadConfig('nextjs', null, 'core-3'); + + expect(config).not.toBeNull(); + expect(config.id).toBe('core-3'); + }); + + it('returns null when no release is provided and version is null', async () => { + const config = await loadConfig('nextjs', null); + + expect(config).toBeNull(); + }); +}); diff --git a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js index 28543456dbe..356606ef9ee 100644 --- a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js +++ b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js @@ -56,6 +56,11 @@ describe('getSdkVersion', () => { const version = getSdkVersion('nextjs', getFixturePath('no-clerk')); expect(version).toBeNull(); }); + + it('returns null for catalog: protocol versions', () => { + const version = getSdkVersion('nextjs', getFixturePath('nextjs-catalog')); + expect(version).toBeNull(); + }); }); describe('getMajorVersion', () => { @@ -78,6 +83,18 @@ describe('getMajorVersion', () => { it('returns null for invalid semver', () => { expect(getMajorVersion('invalid')).toBeNull(); }); + + it('returns null for catalog: protocol', () => { + expect(getMajorVersion('catalog:')).toBeNull(); + }); + + it('returns null for catalog:default', () => { + expect(getMajorVersion('catalog:default')).toBeNull(); + }); + + it('returns null for workspace: protocol', () => { + expect(getMajorVersion('workspace:*')).toBeNull(); + }); }); describe('normalizeSdkName', () => { diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 54eb7deef7f..55e6121642c 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import meow from 'meow'; -import { getOldPackageName, getTargetPackageName, loadConfig } from './config.js'; +import { getAvailableReleases, getOldPackageName, getTargetPackageName, loadConfig } from './config.js'; import { createSpinner, promptConfirm, @@ -119,15 +119,52 @@ async function main() { const currentVersion = getSdkVersion(sdk, options.dir); const packageManager = detectPackageManager(options.dir); - // Step 3: Load version config - const config = await loadConfig(sdk, currentVersion, options.release); + // Step 3: If version couldn't be detected and no release specified, prompt user + let release = options.release; + + if (currentVersion === null && !release) { + const availableReleases = getAvailableReleases(); + + if (availableReleases.length === 0) { + renderError('No upgrade configurations found.'); + process.exit(1); + } + + renderWarning( + `Could not detect your @clerk/${sdk} version (you may be using catalog: protocol or a non-standard version specifier).`, + ); + renderNewline(); + + if (!isInteractive) { + renderError('Please provide --release flag in non-interactive mode.'); + renderText('Available releases: ' + availableReleases.join(', ')); + process.exit(1); + } + + const releaseOptions = availableReleases.map(r => ({ + label: r.replace('-', ' ').replace(/\b\w/g, c => c.toUpperCase()), + value: r, + })); + + release = await promptSelect('Which upgrade would you like to perform?', releaseOptions); + + if (!release) { + renderError('No release selected. Exiting.'); + process.exit(1); + } + + renderNewline(); + } + + // Step 4: Load version config + 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.`); process.exit(1); } - // Step 4: Display configuration + // Step 5: Display configuration renderConfig({ sdk, currentVersion, @@ -145,7 +182,7 @@ async function main() { console.log(''); - // Step 5: Handle upgrade status + // Step 6: Handle upgrade status if (options.skipUpgrade) { renderText('Skipping package upgrade (--skip-upgrade flag)', 'yellow'); renderNewline(); @@ -155,7 +192,7 @@ async function main() { await performUpgrade(sdk, packageManager, config, options); } - // Step 6: Run codemods + // Step 7: Run codemods if (config.codemods?.length > 0) { renderText(`Running ${config.codemods.length} codemod(s)...`, 'blue'); await runCodemods(config, sdk, options); @@ -163,14 +200,14 @@ async function main() { renderNewline(); } - // Step 7: Run scans + // Step 8: Run scans if (config.changes?.length > 0) { renderText('Scanning for additional breaking changes...', 'blue'); const results = await runScans(config, sdk, options); renderScanResults(results, config.docsUrl); } - // Step 8: Done + // Step 9: Done renderComplete(sdk, config.docsUrl); } diff --git a/packages/upgrade/src/config.js b/packages/upgrade/src/config.js index 3316b03d400..00abfad009c 100644 --- a/packages/upgrade/src/config.js +++ b/packages/upgrade/src/config.js @@ -7,6 +7,16 @@ import matter from 'gray-matter'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const VERSIONS_DIR = path.join(__dirname, 'versions'); +export function getAvailableReleases() { + return fs + .readdirSync(VERSIONS_DIR, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + .filter(name => fs.existsSync(path.join(VERSIONS_DIR, name, 'index.js'))) + .sort() + .reverse(); +} + export async function loadConfig(sdk, currentVersion, release) { const versionDirs = fs .readdirSync(VERSIONS_DIR, { withFileTypes: true })