diff --git a/.changeset/busy-tires-admire.md b/.changeset/busy-tires-admire.md new file mode 100644 index 00000000000..ec508e156e7 --- /dev/null +++ b/.changeset/busy-tires-admire.md @@ -0,0 +1,5 @@ +--- +'@clerk/upgrade': major +--- + +Updates the upgrade CLI to support Core 3 changes. If you need to upgrade to an older release, use the previous major version of this package. diff --git a/eslint.config.mjs b/eslint.config.mjs index 8141869b880..6491df5edfe 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -176,6 +176,7 @@ export default tseslint.config([ 'packages/clerk-js/rspack.config.js', 'packages/shared/src/compiled/path-to-regexp/index.js', 'packages/shared/tsdown.config.mjs', + 'packages/upgrade/src/__tests__/fixtures/**/*', ], }, { @@ -488,7 +489,7 @@ export default tseslint.config([ name: 'packages/upgrade', files: ['packages/upgrade/src/**/*'], rules: { - 'import/no-unresolved': ['error', { ignore: ['^#', '^~', '@inkjs/ui', '^ink'] }], + 'custom-rules/no-unstable-methods': 'off', 'react/no-unescaped-entities': 'off', '@typescript-eslint/no-floating-promises': 'warn', }, diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index 6ab544e210e..9679a7cd17d 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -32,21 +32,15 @@ ] }, "dependencies": { - "@inkjs/ui": "^2.0.0", - "@jescalan/ink-markdown": "^2.0.0", + "chalk": "^5.3.0", "ejs": "3.1.10", "execa": "9.4.1", "globby": "^14.0.1", "gray-matter": "^4.0.3", "index-to-position": "^0.1.2", - "ink": "^5.0.1", - "ink-big-text": "^2.0.0", - "ink-gradient": "^3.0.0", - "ink-link": "^4.1.0", "jscodeshift": "^17.0.0", "marked": "^11.1.1", "meow": "^11.0.0", - "react": "catalog:react", "read-pkg": "^9.0.1", "semver-regex": "^4.0.5", "temp-dir": "^3.0.0" diff --git a/packages/upgrade/src/__tests__/fixtures/expo-old-package/package-lock.json b/packages/upgrade/src/__tests__/fixtures/expo-old-package/package-lock.json new file mode 100644 index 00000000000..20b64046956 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/expo-old-package/package-lock.json @@ -0,0 +1,5 @@ +{ + "name": "test-expo-old", + "lockfileVersion": 3 +} + diff --git a/packages/upgrade/src/__tests__/fixtures/expo-old-package/package.json b/packages/upgrade/src/__tests__/fixtures/expo-old-package/package.json new file mode 100644 index 00000000000..d83bd5a0d74 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/expo-old-package/package.json @@ -0,0 +1,10 @@ +{ + "name": "test-expo-old", + "version": "1.0.0", + "dependencies": { + "@clerk/clerk-expo": "^2.0.0", + "expo": "^50.0.0", + "react": "^18.0.0", + "react-native": "^0.73.0" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/expo-old-package/src/App.tsx b/packages/upgrade/src/__tests__/fixtures/expo-old-package/src/App.tsx new file mode 100644 index 00000000000..3e9c94dad4b --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/expo-old-package/src/App.tsx @@ -0,0 +1,14 @@ +import { ClerkProvider, useAuth } from '@clerk/clerk-expo'; + +export default function App() { + return ( + + + + ); +} + +function AuthStatus() { + const { isSignedIn } = useAuth(); + return {isSignedIn ? 'Signed in' : 'Signed out'}; +} diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v6/package.json b/packages/upgrade/src/__tests__/fixtures/nextjs-v6/package.json new file mode 100644 index 00000000000..b40ee864533 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v6/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-nextjs-v6", + "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/pnpm-lock.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-v6/pnpm-lock.yaml new file mode 100644 index 00000000000..c77e7bab7ac --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v6/pnpm-lock.yaml @@ -0,0 +1,2 @@ +lockfileVersion: '9.0' + diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v6/src/app.tsx b/packages/upgrade/src/__tests__/fixtures/nextjs-v6/src/app.tsx new file mode 100644 index 00000000000..26f8412cfb3 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v6/src/app.tsx @@ -0,0 +1,17 @@ +import { ClerkProvider, useAuth } from '@clerk/nextjs'; +import { useUser } from '@clerk/clerk-react'; + +export default function App({ children }) { + return {children}; +} + +export function UserProfile() { + const { isSignedIn } = useAuth(); + const { user } = useUser(); + + if (!isSignedIn) { + return
Not signed in
; + } + + return
Hello, {user?.firstName}
; +} diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v7/package.json b/packages/upgrade/src/__tests__/fixtures/nextjs-v7/package.json new file mode 100644 index 00000000000..65b0c533647 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v7/package.json @@ -0,0 +1,9 @@ +{ + "name": "test-nextjs-v7", + "version": "1.0.0", + "dependencies": { + "@clerk/nextjs": "^7.0.0", + "next": "^14.0.0", + "react": "^18.0.0" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v7/pnpm-lock.yaml b/packages/upgrade/src/__tests__/fixtures/nextjs-v7/pnpm-lock.yaml new file mode 100644 index 00000000000..c77e7bab7ac --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v7/pnpm-lock.yaml @@ -0,0 +1,2 @@ +lockfileVersion: '9.0' + diff --git a/packages/upgrade/src/__tests__/fixtures/nextjs-v7/src/app.tsx b/packages/upgrade/src/__tests__/fixtures/nextjs-v7/src/app.tsx new file mode 100644 index 00000000000..d3c90be745f --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v7/src/app.tsx @@ -0,0 +1,16 @@ +import { ClerkProvider, useAuth, useUser } from '@clerk/nextjs'; + +export default function App({ children }) { + return {children}; +} + +export function UserProfile() { + const { isSignedIn } = useAuth(); + const { user } = useUser(); + + if (!isSignedIn) { + return
Not signed in
; + } + + return
Hello, {user?.firstName}
; +} diff --git a/packages/upgrade/src/__tests__/fixtures/no-clerk/package.json b/packages/upgrade/src/__tests__/fixtures/no-clerk/package.json new file mode 100644 index 00000000000..270aeaec8a6 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/no-clerk/package.json @@ -0,0 +1,7 @@ +{ + "name": "test-no-clerk", + "version": "1.0.0", + "dependencies": { + "react": "^18.0.0" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/react-v6/package.json b/packages/upgrade/src/__tests__/fixtures/react-v6/package.json new file mode 100644 index 00000000000..ea6561a2dc5 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/react-v6/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-react-v6", + "version": "1.0.0", + "dependencies": { + "@clerk/clerk-react": "^5.0.0", + "react": "^18.0.0" + } +} diff --git a/packages/upgrade/src/__tests__/fixtures/react-v6/src/App.tsx b/packages/upgrade/src/__tests__/fixtures/react-v6/src/App.tsx new file mode 100644 index 00000000000..b8b15008a74 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/react-v6/src/App.tsx @@ -0,0 +1,19 @@ +import { ClerkProvider, useUser } from '@clerk/react'; + +export default function App() { + return ( + + + + ); +} + +function UserInfo() { + const { user, isSignedIn } = useUser(); + + if (!isSignedIn) { + return
Please sign in
; + } + + return
Welcome, {user?.firstName}
; +} diff --git a/packages/upgrade/src/__tests__/fixtures/react-v6/yarn.lock b/packages/upgrade/src/__tests__/fixtures/react-v6/yarn.lock new file mode 100644 index 00000000000..58cc4ab5364 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/react-v6/yarn.lock @@ -0,0 +1,2 @@ +# yarn lockfile v1 + diff --git a/packages/upgrade/src/__tests__/helpers/create-fixture.js b/packages/upgrade/src/__tests__/helpers/create-fixture.js new file mode 100644 index 00000000000..800cdb87884 --- /dev/null +++ b/packages/upgrade/src/__tests__/helpers/create-fixture.js @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures'); + +export function getFixturePath(fixtureName) { + return path.join(FIXTURES_DIR, fixtureName); +} + +export function createTempFixture(fixtureName) { + const sourcePath = getFixturePath(fixtureName); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `clerk-upgrade-test-${fixtureName}-`)); + + copyDirSync(sourcePath, tempDir); + + return { + path: tempDir, + cleanup() { + fs.rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +function copyDirSync(src, dest) { + fs.mkdirSync(dest, { recursive: true }); + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDirSync(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +export function readFixtureFile(fixtureName, filePath) { + return fs.readFileSync(path.join(getFixturePath(fixtureName), filePath), 'utf8'); +} + +export function writeFixtureFile(tempPath, filePath, content) { + const fullPath = path.join(tempPath, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content, 'utf8'); +} + +export function readTempFile(tempPath, filePath) { + return fs.readFileSync(path.join(tempPath, filePath), 'utf8'); +} + +export function fileExists(tempPath, filePath) { + return fs.existsSync(path.join(tempPath, filePath)); +} diff --git a/packages/upgrade/src/__tests__/integration/cli.test.js b/packages/upgrade/src/__tests__/integration/cli.test.js new file mode 100644 index 00000000000..76f001a7169 --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -0,0 +1,291 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createTempFixture, getFixturePath } from '../helpers/create-fixture.js'; + +// Toggle this to true to debug the CLI output during the test run +const DEBUG_OUTPUT = false; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI_PATH = path.resolve(__dirname, '../../cli.js'); + +function runCli(args = [], options = {}) { + return new Promise((resolve, reject) => { + const child = spawn('node', [CLI_PATH, ...args], { + cwd: options.cwd || process.cwd(), + env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', data => { + stdout += data.toString(); + if (DEBUG_OUTPUT) { + console.log(data.toString()); + } + }); + + child.stderr.on('data', data => { + stderr += data.toString(); + if (DEBUG_OUTPUT) { + console.error(data.toString()); + } + }); + + // Send input if provided (for interactive prompts) + if (options.input) { + child.stdin.write(options.input); + child.stdin.end(); + } + + // Set timeout to kill the process + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + resolve({ stdout, stderr, exitCode: null, timedOut: true }); + }, options.timeout || 10000); + + child.on('close', exitCode => { + clearTimeout(timeout); + resolve({ stdout, stderr, exitCode, timedOut: false }); + }); + + child.on('error', err => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +describe('CLI Integration', () => { + describe('--help flag', () => { + it('displays help text', async () => { + const result = await runCli(['--help']); + + expect(result.stdout).toContain('Usage'); + expect(result.stdout).toContain('npx @clerk/upgrade'); + expect(result.stdout).toContain('--sdk'); + expect(result.stdout).toContain('--dir'); + expect(result.stdout).toContain('--dry-run'); + expect(result.stdout).toContain('--skip-upgrade'); + expect(result.stdout).toContain('--release'); + expect(result.exitCode).toBe(0); + }); + }); + + describe('--version flag', () => { + it('displays version', async () => { + const result = await runCli(['--version']); + + expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + expect(result.exitCode).toBe(0); + }); + }); + + describe('SDK Detection', () => { + it('detects nextjs SDK from project directory', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + // Combine stdout and stderr for full output + const output = result.stdout + result.stderr; + expect(output).toContain('@clerk/nextjs'); + expect(output).toContain('dry run'); + }); + + it('detects nextjs v7 as already upgraded', async () => { + const dir = getFixturePath('nextjs-v7'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('@clerk/nextjs'); + expect(result.stdout).toContain('already on the latest'); + }); + + it('errors when SDK not detected and not provided in non-interactive mode', async () => { + const dir = getFixturePath('no-clerk'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 5000 }); + + // Error messages go to stderr via console.error + const output = result.stdout + result.stderr; + expect(output).toContain('Could not detect Clerk SDK'); + expect(output).toContain('--sdk'); + expect(result.exitCode).toBe(1); + }); + }); + + describe('--sdk flag', () => { + it('accepts explicit SDK specification', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--sdk', 'nextjs', '--dry-run', '--skip-codemods'], { + timeout: 15000, + }); + + expect(result.stdout).toContain('@clerk/nextjs'); + }); + + it('accepts @clerk/ prefixed SDK name', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--sdk', '@clerk/nextjs', '--dry-run', '--skip-codemods'], { + timeout: 15000, + }); + + expect(result.stdout).toContain('@clerk/nextjs'); + }); + }); + + describe('--dry-run flag', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('nextjs-v6'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('shows what would be done without making changes', async () => { + const result = await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('[dry run]'); + }); + + it('does not modify package.json in dry-run mode', async () => { + const fs = await import('node:fs'); + const pkgBefore = fs.readFileSync(path.join(fixture.path, 'package.json'), 'utf8'); + + await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + const pkgAfter = fs.readFileSync(path.join(fixture.path, 'package.json'), 'utf8'); + expect(pkgAfter).toBe(pkgBefore); + }); + }); + + describe('Version Display', () => { + it('shows current version in output', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('v6'); + }); + + it('shows upgrade path in output', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('v6 → v7'); + }); + }); + + describe('Package Manager Detection', () => { + it('detects pnpm from fixture', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('pnpm'); + }); + + it('detects yarn from fixture', async () => { + const dir = getFixturePath('react-v6'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toMatch(/yarn/i); + }); + + it('detects npm from fixture', async () => { + const dir = getFixturePath('expo-old-package'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toMatch(/npm/i); + }); + }); + + describe('Legacy Package Names', () => { + it('handles @clerk/clerk-react legacy package', async () => { + const dir = getFixturePath('react-v6'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('@clerk/react'); + }); + + it('handles @clerk/clerk-expo legacy package', async () => { + const dir = getFixturePath('expo-old-package'); + const result = await runCli(['--dir', dir, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('@clerk/expo'); + }); + }); + + describe('Codemods', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('nextjs-v6'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('lists codemods that would run in dry-run mode', async () => { + const result = await runCli(['--dir', fixture.path, '--dry-run', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('codemod'); + }); + }); + + describe('--skip-upgrade flag', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('nextjs-v6'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('skips the package upgrade step', async () => { + const result = await runCli(['--dir', fixture.path, '--skip-upgrade', '--skip-codemods'], { timeout: 15000 }); + + expect(result.stdout).toContain('Skipping package upgrade'); + expect(result.stdout).toContain('--skip-upgrade'); + }); + + it('does not modify package.json when skipping upgrade', async () => { + const fs = await import('node:fs'); + const pkgBefore = fs.readFileSync(path.join(fixture.path, 'package.json'), 'utf8'); + + await runCli(['--dir', fixture.path, '--skip-upgrade', '--skip-codemods'], { timeout: 15000 }); + + const pkgAfter = fs.readFileSync(path.join(fixture.path, 'package.json'), 'utf8'); + expect(pkgAfter).toBe(pkgBefore); + }); + }); + + describe('--release flag', () => { + it('loads specific release configuration', async () => { + const dir = getFixturePath('nextjs-v7'); + const result = await runCli(['--dir', dir, '--release', 'core-3', '--dry-run', '--skip-codemods'], { + timeout: 15000, + }); + + expect(result.stdout).toContain('core-3'); + }); + + it('errors when release does not exist', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--release', 'nonexistent-release', '--dry-run', '--skip-codemods'], { + timeout: 15000, + }); + + const output = result.stdout + result.stderr; + expect(output).toContain('No upgrade path found'); + expect(result.exitCode).toBe(1); + }); + }); +}); diff --git a/packages/upgrade/src/__tests__/integration/config.test.js b/packages/upgrade/src/__tests__/integration/config.test.js new file mode 100644 index 00000000000..79a56976f2c --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/config.test.js @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { getOldPackageName, getTargetPackageName, loadConfig } from '../../config.js'; + +describe('loadConfig', () => { + it('returns config with needsUpgrade: true for nextjs v6', async () => { + const config = await loadConfig('nextjs', 6); + + expect(config).not.toBeNull(); + expect(config.id).toBe('core-3'); + expect(config.needsUpgrade).toBe(true); + expect(config.alreadyUpgraded).toBe(false); + }); + + it('returns config with alreadyUpgraded: true for nextjs v7', async () => { + const config = await loadConfig('nextjs', 7); + + expect(config).not.toBeNull(); + expect(config.id).toBe('core-3'); + expect(config.needsUpgrade).toBe(false); + expect(config.alreadyUpgraded).toBe(true); + }); + + it('returns config with needsUpgrade: true for react v6', async () => { + const config = await loadConfig('react', 6); + + expect(config).not.toBeNull(); + expect(config.needsUpgrade).toBe(true); + }); + + it('returns config with needsUpgrade: true for expo v2', async () => { + const config = await loadConfig('expo', 2); + + expect(config).not.toBeNull(); + expect(config.needsUpgrade).toBe(true); + }); + + it('returns null for unsupported SDK version (too old)', async () => { + const config = await loadConfig('nextjs', 4); + + expect(config).toBeNull(); + }); + + it('loads codemods array from config', async () => { + const config = await loadConfig('nextjs', 6); + + expect(config.codemods).toBeDefined(); + expect(Array.isArray(config.codemods)).toBe(true); + expect(config.codemods.length).toBeGreaterThan(0); + }); + + it('loads changes array from config', async () => { + const config = await loadConfig('nextjs', 6); + + expect(config.changes).toBeDefined(); + expect(Array.isArray(config.changes)).toBe(true); + }); + + it('includes docsUrl in config', async () => { + const config = await loadConfig('nextjs', 6); + + expect(config.docsUrl).toBeDefined(); + expect(config.docsUrl).toContain('clerk.com'); + }); + + describe('release parameter', () => { + it('loads specific release when provided', async () => { + const config = await loadConfig('nextjs', 7, 'core-3'); + + expect(config).not.toBeNull(); + expect(config.id).toBe('core-3'); + }); + + it('returns null for non-existent release', async () => { + const config = await loadConfig('nextjs', 6, 'nonexistent-release'); + + expect(config).toBeNull(); + }); + + it('ignores version status when release is specified', async () => { + const config = await loadConfig('nextjs', 7, 'core-3'); + + expect(config).not.toBeNull(); + expect(config.alreadyUpgraded).toBe(true); + }); + + it('loads changes for specific release', async () => { + const config = await loadConfig('nextjs', 6, 'core-3'); + + expect(config.changes).toBeDefined(); + expect(Array.isArray(config.changes)).toBe(true); + }); + }); +}); + +describe('getTargetPackageName', () => { + it('returns @clerk/react for react sdk', () => { + expect(getTargetPackageName('react')).toBe('@clerk/react'); + }); + + it('returns @clerk/react for clerk-react sdk', () => { + expect(getTargetPackageName('clerk-react')).toBe('@clerk/react'); + }); + + it('returns @clerk/expo for expo sdk', () => { + expect(getTargetPackageName('expo')).toBe('@clerk/expo'); + }); + + it('returns @clerk/expo for clerk-expo sdk', () => { + expect(getTargetPackageName('clerk-expo')).toBe('@clerk/expo'); + }); + + it('returns @clerk/nextjs for nextjs sdk', () => { + expect(getTargetPackageName('nextjs')).toBe('@clerk/nextjs'); + }); +}); + +describe('getOldPackageName', () => { + it('returns @clerk/clerk-react for react sdk', () => { + expect(getOldPackageName('react')).toBe('@clerk/clerk-react'); + }); + + it('returns @clerk/clerk-expo for expo sdk', () => { + expect(getOldPackageName('expo')).toBe('@clerk/clerk-expo'); + }); + + it('returns null for nextjs sdk (no rename)', () => { + expect(getOldPackageName('nextjs')).toBeNull(); + }); +}); diff --git a/packages/upgrade/src/__tests__/integration/detect-sdk.test.js b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js new file mode 100644 index 00000000000..28543456dbe --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/detect-sdk.test.js @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { detectSdk, getMajorVersion, getSdkVersion, normalizeSdkName } from '../../util/detect-sdk.js'; +import { detectPackageManager } from '../../util/package-manager.js'; +import { getFixturePath } from '../helpers/create-fixture.js'; + +describe('detectSdk', () => { + it('detects @clerk/nextjs from package.json', () => { + const sdk = detectSdk(getFixturePath('nextjs-v6')); + expect(sdk).toBe('nextjs'); + }); + + it('detects @clerk/nextjs v7 from package.json', () => { + const sdk = detectSdk(getFixturePath('nextjs-v7')); + expect(sdk).toBe('nextjs'); + }); + + it('detects @clerk/clerk-react (legacy name) from package.json', () => { + const sdk = detectSdk(getFixturePath('react-v6')); + expect(sdk).toBe('react'); + }); + + it('detects @clerk/clerk-expo (legacy name) from package.json', () => { + const sdk = detectSdk(getFixturePath('expo-old-package')); + expect(sdk).toBe('expo'); + }); + + it('returns null when no Clerk SDK is found', () => { + const sdk = detectSdk(getFixturePath('no-clerk')); + expect(sdk).toBeNull(); + }); +}); + +describe('getSdkVersion', () => { + it('returns major version 6 for nextjs-v6 fixture', () => { + const version = getSdkVersion('nextjs', getFixturePath('nextjs-v6')); + expect(version).toBe(6); + }); + + it('returns major version 7 for nextjs-v7 fixture', () => { + const version = getSdkVersion('nextjs', getFixturePath('nextjs-v7')); + expect(version).toBe(7); + }); + + it('returns major version 5 for clerk-react fixture', () => { + const version = getSdkVersion('clerk-react', getFixturePath('react-v6')); + expect(version).toBe(5); + }); + + it('returns major version 2 for clerk-expo fixture', () => { + const version = getSdkVersion('clerk-expo', getFixturePath('expo-old-package')); + expect(version).toBe(2); + }); + + it('returns null when SDK is not found', () => { + const version = getSdkVersion('nextjs', getFixturePath('no-clerk')); + expect(version).toBeNull(); + }); +}); + +describe('getMajorVersion', () => { + it('parses ^6.0.0 as version 6', () => { + expect(getMajorVersion('^6.0.0')).toBe(6); + }); + + it('parses ~7.1.2 as version 7', () => { + expect(getMajorVersion('~7.1.2')).toBe(7); + }); + + it('parses 5.0.0 as version 5', () => { + expect(getMajorVersion('5.0.0')).toBe(5); + }); + + it('parses 14.2.3 as version 14', () => { + expect(getMajorVersion('14.2.3')).toBe(14); + }); + + it('returns null for invalid semver', () => { + expect(getMajorVersion('invalid')).toBeNull(); + }); +}); + +describe('normalizeSdkName', () => { + it('returns null for null input', () => { + expect(normalizeSdkName(null)).toBeNull(); + }); + + it('strips @clerk/ prefix', () => { + expect(normalizeSdkName('@clerk/nextjs')).toBe('nextjs'); + }); + + it('converts clerk-react to react', () => { + expect(normalizeSdkName('clerk-react')).toBe('react'); + }); + + it('converts clerk-expo to expo', () => { + expect(normalizeSdkName('clerk-expo')).toBe('expo'); + }); + + it('returns name unchanged for standard names', () => { + expect(normalizeSdkName('nextjs')).toBe('nextjs'); + }); +}); + +describe('detectPackageManager', () => { + it('detects pnpm from pnpm-lock.yaml', () => { + const pm = detectPackageManager(getFixturePath('nextjs-v6')); + expect(pm).toBe('pnpm'); + }); + + it('detects yarn from yarn.lock', () => { + const pm = detectPackageManager(getFixturePath('react-v6')); + expect(pm).toBe('yarn'); + }); + + it('detects npm from package-lock.json', () => { + const pm = detectPackageManager(getFixturePath('expo-old-package')); + expect(pm).toBe('npm'); + }); + + it('defaults to npm when no lock file exists', () => { + const pm = detectPackageManager(getFixturePath('no-clerk')); + expect(pm).toBe('npm'); + }); +}); diff --git a/packages/upgrade/src/__tests__/integration/runner.test.js b/packages/upgrade/src/__tests__/integration/runner.test.js new file mode 100644 index 00000000000..e3e5b2fcc72 --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/runner.test.js @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { loadConfig } from '../../config.js'; +import { runScans } from '../../runner.js'; +import { createTempFixture } from '../helpers/create-fixture.js'; + +vi.mock('../../render.js', () => ({ + colors: { reset: '', bold: '', yellow: '', gray: '' }, + createSpinner: vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + success: vi.fn(), + error: vi.fn(), + })), + promptText: vi.fn((msg, defaultValue) => defaultValue), + renderCodemodResults: vi.fn(), + renderText: vi.fn(), +})); + +describe('runScans', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('nextjs-v6'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('finds patterns in fixture files', async () => { + const config = await loadConfig('nextjs', 6); + const options = { + dir: fixture.path, + ignore: [], + }; + + const results = await runScans(config, 'nextjs', options); + + expect(results.length).toBeGreaterThan(0); + }); + + it('returns empty array when no matchers match', async () => { + const config = await loadConfig('nextjs', 6); + config.changes = []; + + const options = { + dir: fixture.path, + ignore: [], + }; + + const results = await runScans(config, 'nextjs', options); + + expect(results).toEqual([]); + }); + + it('respects ignore patterns', async () => { + const config = await loadConfig('nextjs', 6); + const options = { + dir: fixture.path, + ignore: ['**/src/**'], + }; + + const results = await runScans(config, 'nextjs', options); + + expect(results).toEqual([]); + }); +}); diff --git a/packages/upgrade/src/app.js b/packages/upgrade/src/app.js deleted file mode 100644 index 92f69d593ca..00000000000 --- a/packages/upgrade/src/app.js +++ /dev/null @@ -1,285 +0,0 @@ -import { MultiSelect, Select, TextInput } from '@inkjs/ui'; -import { Newline, Text, useApp } from 'ink'; -import React, { useEffect, useState } from 'react'; - -import { Header } from './components/Header.js'; -import { Scan } from './components/Scan.js'; -import { SDKWorkflow } from './components/SDKWorkflow.js'; -import SDKS from './constants/sdks.js'; -import guessFrameworks from './util/guess-framework.js'; - -/** - * Main CLI application component for handling Clerk SDK upgrades. - * - * @param {Object} props - The `props` object. - * @param {string} [props.dir] - The directory to scan for files. - * @param {boolean} [props.disableTelemetry=false] - Flag to disable telemetry. - * @param {string} [props.fromVersion] - The current version of the SDK. - * @param {Array} [props.ignore] - List of files or directories to ignore. - * @param {boolean} [props.noWarnings=false] - Flag to disable warnings. - * @param {string} [props.sdk] - The SDK to upgrade. - * @param {string} [props.toVersion] - The target version of the SDK. - * @param {boolean} [props.yolo=false] - Flag to enable YOLO mode. - * - * @returns {JSX.Element} The rendered component. - */ -export default function App(props) { - const { noWarnings = false, disableTelemetry = false } = props; - const { exit } = useApp(); - - const [yolo, setYolo] = useState(props.yolo ?? false); - const [sdks, setSdks] = useState(props.sdk ? [props.sdk] : []); - const [sdkGuesses, setSdkGuesses] = useState([]); - const [sdkGuessConfirmed, setSdkGuessConfirmed] = useState(false); - const [sdkGuessAttempted, setSdkGuessAttempted] = useState(false); - const [fromVersion, setFromVersion] = useState(props.fromVersion); - - const [toVersion, setToVersion] = useState(props.toVersion); - const [dir, setDir] = useState(props.dir); - const [ignore, setIgnore] = useState(props.ignore ?? []); - const [configComplete, setConfigComplete] = useState(false); - const [configVerified, setConfigVerified] = useState(false); - const [uuid, setUuid] = useState(); - - if (yolo) { - setSdks(SDKS.map(s => s.value)); - setYolo(false); - } - - useEffect(() => { - if (toVersion === 'core-2') { - setFromVersion('core-1'); - } - }, [toVersion]); - - useEffect(() => { - if (fromVersion === 'core-1') { - setToVersion('core-2'); - } - }, [fromVersion]); - - // Handle the individual SDK upgrade - if ( - !fromVersion && - !toVersion && - ['nextjs', 'clerk-react', 'clerk-expo', 'react-router', 'tanstack-react-start'].includes(sdks[0]) - ) { - return ; - } - - // We try to guess which SDK they are using - if (isEmpty(sdks) && isEmpty(sdkGuesses) && !sdkGuessAttempted) { - if (!dir) { - return setDir(process.cwd()); - } - const { guesses, _uuid } = guessFrameworks(dir, disableTelemetry); - setUuid(_uuid); - setSdkGuesses(guesses); - setSdkGuessAttempted(true); - } - - // No support for v3 or below, sadly - if (parseInt(fromVersion) < 4) { - return We're so sorry, but this tool only supports migration from version 4 and above.; - } - - // If they are trying to/from the same version, that's an error - if (parseInt(fromVersion) === parseInt(toVersion)) { - return You are already on version {toVersion}, so there's no need to migrate!; - } - - return ( - <> -
- - {/* Welcome to the upgrade script! */} - {!configComplete && ( - <> - - Hello friend! We're excited to help you upgrade Clerk modules. Before we get - started, a couple questions... - - - - )} - - {/* Verify our guess at what their SDK is, if we have one */} - {isEmpty(sdks) && !isEmpty(sdkGuesses) && !sdkGuessConfirmed && ( - <> - {sdkGuesses.length > 1 ? ( - <> - It looks like you are using the following Clerk SDKs in your project: - {sdkGuesses.map(guess => ( - - {' '}- {guess.label} - - ))} - Is that right? - - ) : ( - - It looks like you are using the {sdkGuesses[0].label} Clerk SDK in your project. Is that - right? - - )} - - { - setFromVersionGuessAttempted(true); - // if true, we were right so we set the fromVersion - if (item.value) setFromVersion(item.value); - }} - /> - - )} */} - {/* If we tried to guess and failed, user must manually select */} - {/* {fromVersionGuessAttempted && !fromVersion && ( - <> - - Please select which major version of the Clerk {sdk} SDK you are - currently using: - - setToVersion(item.value)} - /> - - )} */} - {!isEmpty(sdks) && fromVersion && toVersion && !dir && ( - <> - Where would you like for us to scan for files in your project? - (globstar syntax supported) - setDir(val)} - /> - - )} - - {!isEmpty(sdks) && fromVersion && toVersion && dir && isEmpty(ignore) && !configComplete && ( - <> - - Are there any files or directories you'd like to ignore? If so, you can add them below, separated by commas. - We ignore "node_modules" and ".git" by default. - - (globstar syntax supported) - { - setIgnore(val.includes(',') ? val.split(/\s*,\s*/) : [].concat(val)); - setConfigComplete(true); - }} - /> - - )} - - {configComplete && !configVerified && ( - <> - Ok, here's our configuration: - - - Clerk {sdks.length > 1 ? 'SDKs' : 'SDK'} used: - {sdks.toString()} - - - Migrating from - {fromVersion} - to - {toVersion} - - - Looking in the directory - {dir} - {ignore.length > 0 && ( - <> - and ignoring - {ignore.join(', ')} - - )} - - - Does this look right? - { - const numeric = typeof value === 'number' ? value : Number(value); - setVersion(Number.isNaN(numeric) ? 7 : numeric); - setVersionConfirmed(true); - }} - /> - - ); - } - - if (sdk === 'nextjs') { - return ( - - ); - } - - if (['clerk-react', 'clerk-expo', 'react-router', 'tanstack-react-start'].includes(sdk)) { - return ( - - ); - } -} - -function NextjsWorkflow({ - done, - runCodemod, - sdk, - setDone, - setRunCodemod, - setUpgradeComplete, - upgradeComplete, - version, -}) { - const [v6CodemodComplete, setV6CodemodComplete] = useState(false); - const [glob, setGlob] = useState(); - - return ( - <> -
- - Clerk SDK used: @clerk/{sdk} - - - Migrating from version: {version} - - {runCodemod ? ( - - Executing codemod: yes - - ) : null} - - {version === 5 && ( - <> - - {upgradeComplete ? ( - - ) : null} - {v6CodemodComplete ? ( - - ) : null} - - )} - {version === 6 && ( - <> - - {upgradeComplete ? ( - - ) : null} - {v6CodemodComplete ? ( - - ) : null} - - )} - {version === 7 && ( - <> - {runCodemod ? ( - <> - - {v6CodemodComplete ? ( - - ) : null} - - ) : ( - <> - - Looks like you are already on the latest version of @clerk/{sdk}. Would you like to - run the associated codemods? - - { - if (value === 'yes') { - setRunCodemod(true); - } else { - setDone(true); - } - }} - options={[ - { label: 'yes', value: 'yes' }, - { label: 'no', value: 'no' }, - ]} - /> - - )} - - )} - {done && ( - - {replacePackage ? ( - <> - Done upgrading to @clerk/{sdk.replace('clerk-', '')} - - ) : ( - <> - Done upgrading @clerk/{sdk} - - )} - - )} - - ); -} diff --git a/packages/upgrade/src/components/Scan.js b/packages/upgrade/src/components/Scan.js deleted file mode 100644 index 1a6cdbd8f48..00000000000 --- a/packages/upgrade/src/components/Scan.js +++ /dev/null @@ -1,206 +0,0 @@ -import { ProgressBar } from '@inkjs/ui'; -import fs from 'fs/promises'; -import { convertPathToPattern, globby } from 'globby'; -import indexToPosition from 'index-to-position'; -import { Newline, Text } from 'ink'; -import path from 'path'; -import React, { useEffect, useState } from 'react'; - -import ExpandableList from '../util/expandable-list.js'; - -export function Scan(props) { - const { fromVersion, toVersion, sdks, dir, ignore, noWarnings, uuid, disableTelemetry } = props; - // NOTE: if the difference between fromVersion and toVersion is greater than 1 - // we need to do a little extra work here and import two matchers, - // sequence them after each other, and clearly mark which version migration - // applies to each log. - // - // This is not yet implemented though since the current state of the script - // only handles a single version. - const [status, setStatus] = useState('Initializing'); - const [progress, setProgress] = useState(0); - const [complete, setComplete] = useState(false); - const [matchers, setMatchers] = useState(); - const [files, setFiles] = useState(); - const [results, setResults] = useState([]); - - // Load matchers - // ------------- - // result = `matchers` set to format: - // { sdkName: [{ title: 'x', matcher: /x/, slug: 'x', ... }] } - useEffect(() => { - setStatus(`Loading data for ${toVersion} migration`); - void import(`../versions/${toVersion}/index.js`).then(version => { - setMatchers( - sdks.reduce((m, sdk) => { - m[sdk] = version.default[sdk]; - return m; - }, {}), - ); - }); - }, [toVersion, sdks]); - - // Get all files from the glob matcher - // ----------------------------------- - // result = `files` set to format: ['/filename', '/other/filename'] - useEffect(() => { - setStatus('Collecting files to scan'); - const pattern = convertPathToPattern(path.resolve(dir)); - - void globby(pattern, { - ignore: [ - 'node_modules/**', - '**/node_modules/**', - '.git/**', - 'package.json', - '**/package.json', - 'package-lock.json', - '**/package-lock.json', - 'yarn.lock', - '**/yarn.lock', - 'pnpm-lock.yaml', - '**/pnpm-lock.yaml', - '**/*.(png|webp|svg|gif|jpg|jpeg)+', - '**/*.(mp4|mkv|wmv|m4v|mov|avi|flv|webm|flac|mka|m4a|aac|ogg)+', - ...ignore, - ].filter(Boolean), - }).then(files => { - setFiles(files); - }); - }, [dir, ignore]); - - // Read files and scan regexes - // --------------------------- - // result = `results` set to format - useEffect(() => { - if (!matchers || !files) { - return; - } - const allResults = {}; - - void Promise.all( - // first we read all the files - files.map(async (file, idx) => { - const content = await fs.readFile(file, 'utf8'); - - // then we run each of the matchers against the file contents - for (const sdk in matchers) { - // returns [{ ...matcher, instances: [{sdk, file, position}] }] - matchers[sdk].map(matcherConfig => { - // run regex against file content, return array of matches - // matcher can be an array or string - let matches = []; - if (Array.isArray(matcherConfig.matcher)) { - matcherConfig.matcher.map(m => { - matches = matches.concat(Array.from(content.matchAll(m))); - }); - } else { - matches = Array.from(content.matchAll(matcherConfig.matcher)); - } - if (matches.length < 1) { - return; - } - - // for each match, add to `instances` array - matches.map(match => { - if (noWarnings && matcherConfig.warning) { - return; - } - - // create if not exists - if (!allResults[matcherConfig.title]) { - allResults[matcherConfig.title] = { instances: [], ...matcherConfig }; - } - - const position = indexToPosition(content, match.index, { oneBased: true }); - const fileRelative = path.relative(process.cwd(), file); - - // when scanning for multiple SDKs, you can get a double match, this logic ensures you don't - if ( - allResults[matcherConfig.title].instances.filter(i => { - return ( - i.position.line === position.line && - i.position.column === position.column && - i.file === fileRelative - ); - }).length > 0 - ) { - return; - } - - allResults[matcherConfig.title].instances.push({ - sdk, - position, - file: fileRelative, - }); - }); - }); - } - - setStatus(`Scanning ${file}`); - setProgress(Math.ceil((idx / files.length) * 100)); - }), - ) - .then(() => { - const aggregatedResults = Object.keys(allResults).map(k => allResults[k]); - setResults(prevResults => [...prevResults, ...aggregatedResults]); - - // Anonymously track how many instances of each breaking change item were encountered. - // This only tracks the name of the breaking change found, and how many instances of it - // were found. It does not send any part of the scanned codebase or any PII. - // It is used internally to help us understand what the most common sticking points are - // for our users so we can appropriate prioritize support/guidance/docs around them. - if (!disableTelemetry) { - void fetch('https://api.segment.io/v1/batch', { - method: 'POST', - headers: { - Authorization: `Basic ${Buffer.from('5TkC1SM87VX2JRJcIGBBmL7sHLRWaIvc:').toString('base64')}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - batch: aggregatedResults.map(item => { - return { - type: 'track', - userId: 'clerk-upgrade-tool', - event: 'Clerk Migration Tool_CLI_Breaking Change Found', - properties: { - appId: `cmt_${uuid}`, - surface: 'Clerk Migration Tool', - location: 'CLI', - title: item.title, - instances: item.instances.length, - fromVersion, - toVersion, - }, - timestamp: new Date().toISOString(), - }; - }), - }), - }); - } - - setComplete(true); - if (Object.keys(allResults).length < 1) { - setStatus('It looks like you have nothing you need to change, upgrade away!'); - } else { - setStatus('File scan complete. See results below!'); - } - }) - .catch(err => { - console.error(err); - }); - }, [matchers, files, noWarnings, disableTelemetry, fromVersion, toVersion, uuid]); - - return complete ? ( - <> - ✓ {status} - - {!!results.length && } - - ) : ( - <> - - {status} - - ); -} diff --git a/packages/upgrade/src/components/UpgradeSDK.js b/packages/upgrade/src/components/UpgradeSDK.js deleted file mode 100644 index ae09e220dd4..00000000000 --- a/packages/upgrade/src/components/UpgradeSDK.js +++ /dev/null @@ -1,128 +0,0 @@ -import { Select, Spinner, StatusMessage } from '@inkjs/ui'; -import { execa } from 'execa'; -import { existsSync } from 'fs'; -import { Newline, Text } from 'ink'; -import React, { useEffect, useState } from 'react'; - -function detectPackageManager() { - if (existsSync('package-lock.json')) { - return 'npm'; - } else if (existsSync('yarn.lock')) { - return 'yarn'; - } else if (existsSync('pnpm-lock.yaml')) { - return 'pnpm'; - } - return undefined; -} - -/** - * - * @param {string} sdk - * @param {string} packageManager - * @param {boolean} replacePackage - * @returns - */ -function upgradeCommand(sdk, packageManager, replacePackage = false) { - let packageName = `@clerk/${sdk}`; - if (replacePackage) { - packageName = packageName.replace('clerk-', ''); - } - switch (packageManager) { - case 'yarn': - return `yarn add ${packageName}@latest`; - case 'pnpm': - return `pnpm add ${packageName}@latest`; - default: - return `npm install ${packageName}@latest`; - } -} - -/** - * Component that runs an upgrade command for a given SDK and handles the result. - * - * @component - * @param {Object} props - * @param {Function} props.callback - The callback function to be called after the command execution. - * @param {string} props.sdk - The SDK for which the upgrade command is run. - * @param {boolean} props.replacePackage - Whether to replace legacy `clerk-` packages with their new versions. - * @returns {JSX.Element} The rendered component. - * - * @example - * - */ -export function UpgradeSDK({ callback, sdk, replacePackage = false }) { - const [command, setCommand] = useState(); - const [error, setError] = useState(); - const [packageManager, setPackageManager] = useState(detectPackageManager()); - const [result, setResult] = useState(); - - useEffect(() => { - if (!packageManager) { - return; - } - setCommand(previous => { - if (previous) { - return previous; - } - return upgradeCommand(sdk, packageManager, replacePackage); - }); - if (!command) { - return; - } - - execa({ shell: true })`${command}` - .then(res => { - setResult(res); - }) - .catch(err => { - setError(err); - }) - .finally(() => { - callback(true); - }); - }, [callback, command, packageManager, replacePackage, sdk]); - - return ( - <> - {packageManager ? null : ( - <> - - We could not detect the package manager used in your project. Please select the package manager you are - using - -