diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index 9679a7cd17d..f03344ce51e 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -21,6 +21,7 @@ "dev": "babel --keep-file-extension --out-dir=dist --watch src --copy-files", "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", + "generate-guide": "node scripts/generate-guide.js", "lint": "eslint src/", "lint:publint": "publint", "test": "vitest run", diff --git a/packages/upgrade/scripts/generate-guide.js b/packages/upgrade/scripts/generate-guide.js new file mode 100644 index 00000000000..b434fcdada9 --- /dev/null +++ b/packages/upgrade/scripts/generate-guide.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import matter from 'gray-matter'; +import meow from 'meow'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const VERSIONS_DIR = path.join(__dirname, '../src/versions'); + +const cli = meow( + ` + Usage + $ pnpm run generate-guide --version= --sdk= + + Options + --version Version directory to use (e.g., core-3) + --sdk SDK to generate guide for (e.g., nextjs, react, expo) + + Examples + $ pnpm run generate-guide --version=core-3 --sdk=nextjs + $ pnpm run generate-guide --version=core-3 --sdk=react > react-guide.md +`, + { + importMeta: import.meta, + flags: { + version: { type: 'string', isRequired: true }, + sdk: { type: 'string', isRequired: true }, + }, + }, +); + +async function loadVersionConfig(version) { + const configPath = path.join(VERSIONS_DIR, version, 'index.js'); + + if (!fs.existsSync(configPath)) { + throw new Error(`Version config not found: ${configPath}`); + } + + const moduleUrl = pathToFileURL(configPath).href; + const mod = await import(moduleUrl); + return mod.default ?? mod; +} + +function loadChanges(version, sdk) { + const changesDir = path.join(VERSIONS_DIR, version, 'changes'); + + if (!fs.existsSync(changesDir)) { + return []; + } + + const files = fs.readdirSync(changesDir).filter(f => f.endsWith('.md')); + const changes = []; + + for (const file of files) { + const filePath = path.join(changesDir, file); + const content = fs.readFileSync(filePath, 'utf8'); + const parsed = matter(content); + const fm = parsed.data; + + const packages = fm.packages || ['*']; + const appliesToSdk = packages.includes('*') || packages.includes(sdk); + + if (!appliesToSdk) { + continue; + } + + changes.push({ + title: fm.title, + packages, + category: fm.category || 'breaking', + content: parsed.content.trim(), + slug: file.replace('.md', ''), + }); + } + + return changes; +} + +function groupByCategory(changes) { + const groups = {}; + + for (const change of changes) { + const category = change.category; + if (!groups[category]) { + groups[category] = []; + } + groups[category].push(change); + } + + return groups; +} + +function getCategoryHeading(category) { + const headings = { + breaking: 'Breaking Changes', + 'deprecation-removal': 'Deprecation Removals', + warning: 'Warnings', + }; + return headings[category] || category; +} + +function generateMarkdown(sdk, versionConfig, changes) { + const lines = []; + const versionName = versionConfig.name || versionConfig.id; + + lines.push(`# Upgrading @clerk/${sdk} to ${versionName}`); + lines.push(''); + + if (versionConfig.docsUrl) { + lines.push(`For the full migration guide, see: ${versionConfig.docsUrl}`); + lines.push(''); + } + + const grouped = groupByCategory(changes); + const categoryOrder = ['breaking', 'deprecation-removal', 'warning']; + + for (const category of categoryOrder) { + const categoryChanges = grouped[category]; + if (!categoryChanges || categoryChanges.length === 0) { + continue; + } + + lines.push(`## ${getCategoryHeading(category)}`); + lines.push(''); + + for (const change of categoryChanges) { + lines.push(`### ${change.title}`); + lines.push(''); + lines.push(change.content); + lines.push(''); + } + } + + // Handle any categories not in the predefined order + for (const [category, categoryChanges] of Object.entries(grouped)) { + if (categoryOrder.includes(category)) { + continue; + } + + lines.push(`## ${getCategoryHeading(category)}`); + lines.push(''); + + for (const change of categoryChanges) { + lines.push(`### ${change.title}`); + lines.push(''); + lines.push(change.content); + lines.push(''); + } + } + + return lines.join('\n'); +} + +async function main() { + const { version, sdk } = cli.flags; + + const versionConfig = await loadVersionConfig(version); + const changes = loadChanges(version, sdk); + + if (changes.length === 0) { + console.error(`No changes found for ${sdk} in ${version}`); + process.exit(1); + } + + const markdown = generateMarkdown(sdk, versionConfig, changes); + console.log(markdown); +} + +main().catch(error => { + console.error(error.message); + process.exit(1); +}); diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/package.json b/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/package.json new file mode 100644 index 00000000000..a456488eafa --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-nextjs-v6-scan-issues", + "version": "1.0.0", + "dependencies": { + "@clerk/nextjs": "^6.0.0", + "next": "^14.0.0", + "react": "^18.0.0" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/pnpm-lock.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/pnpm-lock.yaml new file mode 100644 index 00000000000..d57ee1b3a6c --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/pnpm-lock.yaml @@ -0,0 +1,2 @@ +lockfileVersion: '6.0' + diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/src/app.tsx b/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/src/app.tsx new file mode 100644 index 00000000000..f890201f541 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v6-scan-issues/src/app.tsx @@ -0,0 +1,30 @@ +import { ClerkProvider, SignIn, useAuth } from '@clerk/nextjs'; + +export default function App({ children }) { + return ( + + {children} + + ); +} + +export function SignInPage() { + return ( + + ); +} + +export function SamlCallback() { + const { isSignedIn } = useAuth(); + // Handle saml callback + return
SAML SSO Callback
; +} diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js index a68028b2e00..1cf0a60b9c6 100644 --- a/packages/upgrade/src/__tests__/integration/cli.test.js +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -235,4 +235,78 @@ describe('CLI Integration', () => { expect(result.stdout).toContain('codemod'); }); }); + + describe('Scan Results', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('nextjs-v6-scan-issues'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('detects and displays breaking changes found in source files', async () => { + const result = await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 20000 }); + + const output = result.stdout + result.stderr; + + // Should scan for breaking changes + expect(output).toContain('Scanning'); + expect(output).toContain('Scanned'); + }); + + it('shows file paths with line and column numbers for scan results', async () => { + const result = await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 20000 }); + + const output = result.stdout + result.stderr; + + // Should show file:line:column format for detected issues + expect(output).toMatch(/src\/app\.tsx:\d+:\d+/); + }); + + it('displays issue titles for detected breaking changes', async () => { + const result = await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 20000 }); + + const output = result.stdout + result.stderr; + + // Should display the issue titles from the change definitions + // These are defined in the core-3 changes directory + expect(output).toMatch(/potential issue|breaking change/i); + }); + + it('shows migration guide links for detected issues', async () => { + const result = await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 20000 }); + + const output = result.stdout + result.stderr; + + // Should include link to migration guide + expect(output).toContain('migration guide'); + }); + + it('shows instance counts for detected issues', async () => { + const result = await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 20000 }); + + const output = result.stdout + result.stderr; + + // Should show instance counts + expect(output).toMatch(/\d+ instance/i); + }); + + it('shows no breaking changes message when project has no issues', async () => { + const cleanFixture = createTempFixture('nextjs-v7'); + + try { + const result = await runCli(['--dir', cleanFixture.path, '--dry-run', '--skip-codemods'], { timeout: 20000 }); + + const output = result.stdout + result.stderr; + + // When already on latest version, should show success message + expect(output).toContain('already on the latest'); + } finally { + cleanFixture.cleanup(); + } + }); + }); }); diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index aec857d5bd7..bed82ff5bc6 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +import chalk from 'chalk'; import meow from 'meow'; import { getOldPackageName, getTargetPackageName, loadConfig } from './config.js'; @@ -146,9 +147,12 @@ async function main() { await performUpgrade(sdk, packageManager, config, options); } + // Scans triggered early to make it run faster + const scansPromise = config.changes?.length > 0 ? runScans(config, sdk, options) : Promise.resolve([]); + // Step 6: Run codemods if (config.codemods?.length > 0) { - renderText(`Running ${config.codemods.length} codemod(s)...`, 'blue'); + renderText(chalk.bold(`🔧 Running ${config.codemods.length} codemod(s)...`), 'blue'); await runCodemods(config, sdk, options); renderSuccess('All codemods applied'); renderNewline(); @@ -156,9 +160,17 @@ async function main() { // Step 7: 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); + renderText(chalk.bold('🔎 Scanning for additional breaking changes...'), 'blue'); + const spinner = createSpinner('Scanning files for breaking changes...'); + try { + const results = await scansPromise; + spinner.success(chalk.dim(`Scanned ${results.length} files`)); + renderNewline(); + renderScanResults(results, config.docsUrl); + } catch (error) { + spinner.error('Scan failed'); + throw error; + } } // Step 8: Done @@ -195,9 +207,9 @@ async function performUpgrade(sdk, packageManager, config, options) { try { await upgradePackage(packageManager, targetPackage, targetVersion, options.dir); - spinner.success(`Upgraded ${targetPackage} to version ${targetVersion}`); + spinner.success(chalk.green.bold(`✅ Upgraded ${targetPackage} to version ${targetVersion}`)); } catch (error) { - spinner.error(`Failed to upgrade ${targetPackage}`); + spinner.error(chalk.red.bold(`⛔ Failed to upgrade ${targetPackage}`)); renderError(error.message); process.exit(1); } diff --git a/packages/upgrade/src/render.js b/packages/upgrade/src/render.js index 124ec4da2bf..d6973f90b6c 100644 --- a/packages/upgrade/src/render.js +++ b/packages/upgrade/src/render.js @@ -30,7 +30,7 @@ export function renderNewline() { } export function renderConfig({ sdk, currentVersion, fromVersion, toVersion, versionName, dir, packageManager }) { - console.log(`🔧 ${chalk.bold('Upgrade config')}`); + console.log(`⚙️ ${chalk.bold('Upgrade config')}`); const versionSuffix = currentVersion ? ` ${chalk.gray(`(v${currentVersion})`)}` : ''; console.log(`Clerk SDK: ${chalk.green(`@clerk/${sdk}`)}${versionSuffix}`); if (fromVersion && toVersion) { @@ -130,14 +130,14 @@ export function createSpinner(label) { clearInterval(interval); interval = null; } - process.stdout.write(`\r\x1b[K${chalk.green('✓')} ${message}\n`); + process.stdout.write(`\r\x1b[K${message}\n`); }, error(message) { if (interval) { clearInterval(interval); interval = null; } - process.stdout.write(`\r\x1b[K${chalk.red('✗')} ${message}\n`); + process.stdout.write(`\r\x1b[K${message}\n`); }, }; } @@ -149,16 +149,16 @@ export function renderCodemodResults(transform, result) { export function renderScanResults(results, docsUrl) { if (results.length === 0) { - console.log(chalk.green('✓ No breaking changes detected!')); + renderSuccess('No breaking changes detected!'); console.log(''); return; } - console.log(chalk.yellow.bold(`Found ${results.length} potential issue(s) to review:`)); + renderWarning(`Found ${results.length} potential issue(s) to review:`); console.log(''); for (const item of results) { - console.log(chalk.bold(item.title)); + console.log(chalk.bold(`- ${item.title}`)); if (item.warning) { console.log(chalk.yellow('(warning - may not require action)')); } @@ -168,7 +168,7 @@ export function renderScanResults(results, docsUrl) { } const link = docsUrl && item.docsAnchor ? `${docsUrl}#${item.docsAnchor}` : null; if (link) { - console.log(chalk.blue(`→ View in migration guide: ${link}`)); + console.log(chalk.dim(`Migration guide: ${link}`)); } console.log(''); } @@ -178,5 +178,5 @@ export function renderComplete(sdk, docsUrl) { console.log(chalk.green.bold(`✅ Upgrade complete for @clerk/${sdk}`)); console.log(''); console.log(`Review the changes above and test your application before deployment.`); - console.log(chalk.gray(`For more information, see the migration guide: ${docsUrl}`)); + console.log(chalk.dim(`For more information, see the migration guide: ${docsUrl}`)); } diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js index 27b59a79f02..4ff512adc7d 100644 --- a/packages/upgrade/src/runner.js +++ b/packages/upgrade/src/runner.js @@ -65,58 +65,48 @@ export async function runScans(config, sdk, options) { return []; } - const spinner = createSpinner('Scanning files for breaking changes...'); + const pattern = convertPathToPattern(path.resolve(options.dir)); + const files = await globby(pattern, { + ignore: [...GLOBBY_IGNORE, ...(options.ignore || [])], + }); - try { - const pattern = convertPathToPattern(path.resolve(options.dir)); - const files = await globby(pattern, { - ignore: [...GLOBBY_IGNORE, ...(options.ignore || [])], - }); + const results = {}; - const results = {}; + for (let idx = 0; idx < files.length; idx++) { + const file = files[idx]; + const content = await fs.readFile(file, 'utf8'); - for (let idx = 0; idx < files.length; idx++) { - const file = files[idx]; - spinner.update(`Scanning ${path.basename(file)} (${idx + 1}/${files.length})`); + for (const matcher of matchers) { + const matches = findMatches(content, matcher.matcher); - const content = await fs.readFile(file, 'utf8'); + if (matches.length === 0) { + continue; + } - for (const matcher of matchers) { - const matches = findMatches(content, matcher.matcher); + if (!results[matcher.title]) { + results[matcher.title] = { instances: [], ...matcher }; + } - if (matches.length === 0) { - continue; - } + for (const match of matches) { + const position = indexToPosition(content, match.index, { oneBased: true }); + const fileRelative = path.relative(process.cwd(), file); - if (!results[matcher.title]) { - results[matcher.title] = { instances: [], ...matcher }; - } + const isDuplicate = results[matcher.title].instances.some( + i => i.position.line === position.line && i.position.column === position.column && i.file === fileRelative, + ); - for (const match of matches) { - const position = indexToPosition(content, match.index, { oneBased: true }); - const fileRelative = path.relative(process.cwd(), file); - - const isDuplicate = results[matcher.title].instances.some( - i => i.position.line === position.line && i.position.column === position.column && i.file === fileRelative, - ); - - if (!isDuplicate) { - results[matcher.title].instances.push({ - sdk, - position, - file: fileRelative, - }); - } + if (!isDuplicate) { + results[matcher.title].instances.push({ + sdk, + position, + file: fileRelative, + }); } } } - - spinner.success(`Scanned ${files.length} files`); - return Object.values(results); - } catch (error) { - spinner.error('Scan failed'); - throw error; } + + return Object.values(results); } function loadMatchers(config, sdk) { diff --git a/packages/upgrade/src/versions/core-3/changes/checkout-api-changed.md b/packages/upgrade/src/versions/core-3/changes/checkout-api-changed.md index 73adb499fd8..5b4f3f1ddff 100644 --- a/packages/upgrade/src/versions/core-3/changes/checkout-api-changed.md +++ b/packages/upgrade/src/versions/core-3/changes/checkout-api-changed.md @@ -3,7 +3,6 @@ title: '`useCheckout` and `Clerk.checkout()` return value changed' matcher: - 'useCheckout' - 'Clerk\\.checkout' - - '\\.checkout\\(' category: 'breaking' --- diff --git a/packages/upgrade/src/versions/core-3/changes/nextjs-encryption-key-required.md b/packages/upgrade/src/versions/core-3/changes/nextjs-encryption-key-required.md index 844e55d1851..aa92ba1df4a 100644 --- a/packages/upgrade/src/versions/core-3/changes/nextjs-encryption-key-required.md +++ b/packages/upgrade/src/versions/core-3/changes/nextjs-encryption-key-required.md @@ -1,7 +1,7 @@ --- title: 'Encryption key required when passing `secretKey` at runtime' packages: ['nextjs'] -matcher: 'clerkMiddleware\\([\\s\\S]*?secretKey' +matcher: 'clerkMiddleware[\\s\\S]*?secretKey' matcherFlags: 'm' category: 'breaking' ---