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?
-
- )}
-
-