From bac9b4e3ad633b08310c1f78c2387e411d030c3c Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 4 Dec 2025 15:44:19 -0600 Subject: [PATCH 01/16] Adds codemod to transform clerk/themes to clerk/ui/themes --- .../transform-themes-to-ui-themes.fixtures.js | 47 ++++++++++++ .../transform-themes-to-ui-themes.test.js | 13 ++++ .../transform-themes-to-ui-themes.cjs | 74 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 packages/upgrade/src/codemods/__tests__/__fixtures__/transform-themes-to-ui-themes.fixtures.js create mode 100644 packages/upgrade/src/codemods/__tests__/transform-themes-to-ui-themes.test.js create mode 100644 packages/upgrade/src/codemods/transform-themes-to-ui-themes.cjs diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-themes-to-ui-themes.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-themes-to-ui-themes.fixtures.js new file mode 100644 index 00000000000..1240399c01d --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-themes-to-ui-themes.fixtures.js @@ -0,0 +1,47 @@ +export const fixtures = [ + { + name: 'Renames root import', + source: ` +import { dark, light } from '@clerk/themes'; + `, + output: ` +import { dark, light } from "@clerk/ui/themes"; +`, + }, + { + name: 'Renames subpath import', + source: ` +import palette from '@clerk/themes/palette'; + `, + output: ` +import palette from "@clerk/ui/themes/palette"; +`, + }, + { + name: 'Renames require call', + source: ` +const themes = require('@clerk/themes'); + `, + output: ` +const themes = require("@clerk/ui/themes"); +`, + }, + { + name: 'Renames dynamic import', + source: ` +const mod = await import('@clerk/themes/foo'); + `, + output: ` +const mod = await import("@clerk/ui/themes/foo"); +`, + }, + { + name: 'Renames export source', + source: ` +export * from '@clerk/themes'; + `, + output: ` +export * from "@clerk/ui/themes"; +`, + }, +]; diff --git a/packages/upgrade/src/codemods/__tests__/transform-themes-to-ui-themes.test.js b/packages/upgrade/src/codemods/__tests__/transform-themes-to-ui-themes.test.js new file mode 100644 index 00000000000..ad354d25c22 --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/transform-themes-to-ui-themes.test.js @@ -0,0 +1,13 @@ +import { applyTransform } from 'jscodeshift/dist/testUtils'; +import { describe, expect, it } from 'vitest'; + +import transformer from '../transform-themes-to-ui-themes.cjs'; +import { fixtures } from './__fixtures__/transform-themes-to-ui-themes.fixtures'; + +describe('transform-themes-to-ui-themes', () => { + it.each(fixtures)('$name', ({ source, output }) => { + const result = applyTransform(transformer, {}, { source }); + + expect(result).toEqual(output.trim()); + }); +}); diff --git a/packages/upgrade/src/codemods/transform-themes-to-ui-themes.cjs b/packages/upgrade/src/codemods/transform-themes-to-ui-themes.cjs new file mode 100644 index 00000000000..051c4090fcf --- /dev/null +++ b/packages/upgrade/src/codemods/transform-themes-to-ui-themes.cjs @@ -0,0 +1,74 @@ +const LEGACY_PACKAGE = '@clerk/themes'; +const TARGET_PACKAGE = '@clerk/ui/themes'; + +const isStringLiteral = (j, node) => + (j.Literal.check(node) && typeof node.value === 'string') || (j.StringLiteral && j.StringLiteral.check(node)); + +const getReplacement = value => { + if (typeof value !== 'string' || !value.startsWith(LEGACY_PACKAGE)) { + return null; + } + return `${TARGET_PACKAGE}${value.slice(LEGACY_PACKAGE.length)}`; +}; + +module.exports = function transformThemesToUiThemes({ source }, { jscodeshift: j }) { + const root = j(source); + let dirty = false; + + const replaceSourceLiteral = literal => { + if (!isStringLiteral(j, literal)) { + return; + } + const nextValue = getReplacement(literal.value); + if (nextValue && nextValue !== literal.value) { + literal.value = nextValue; + dirty = true; + } + }; + + root.find(j.ImportDeclaration).forEach(path => { + replaceSourceLiteral(path.node.source); + }); + + root.find(j.ExportNamedDeclaration).forEach(path => { + if (path.node.source) { + replaceSourceLiteral(path.node.source); + } + }); + + root.find(j.ExportAllDeclaration).forEach(path => { + replaceSourceLiteral(path.node.source); + }); + + root + .find(j.CallExpression, { + callee: { name: 'require' }, + }) + .forEach(path => { + const [arg] = path.node.arguments || []; + if (arg) { + replaceSourceLiteral(arg); + } + }); + + if (j.ImportExpression) { + root.find(j.ImportExpression).forEach(path => { + replaceSourceLiteral(path.node.source); + }); + } + + root + .find(j.CallExpression, { + callee: { type: 'Import' }, + }) + .forEach(path => { + const [arg] = path.node.arguments || []; + if (arg) { + replaceSourceLiteral(arg); + } + }); + + return dirty ? root.toSource() : undefined; +}; + +module.exports.parser = 'tsx'; From 1392ce530a19704e98ca6b4278cdac45b268faea Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 4 Dec 2025 15:45:08 -0600 Subject: [PATCH 02/16] Adds codemod to transform experimental and unstable prefixes --- ...experimental-unstable-prefixes.fixtures.js | 74 +++ ...ign-experimental-unstable-prefixes.test.js | 13 + ...m-align-experimental-unstable-prefixes.cjs | 478 ++++++++++++++++++ 3 files changed, 565 insertions(+) create mode 100644 packages/upgrade/src/codemods/__tests__/__fixtures__/transform-align-experimental-unstable-prefixes.fixtures.js create mode 100644 packages/upgrade/src/codemods/__tests__/transform-align-experimental-unstable-prefixes.test.js create mode 100644 packages/upgrade/src/codemods/transform-align-experimental-unstable-prefixes.cjs diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-align-experimental-unstable-prefixes.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-align-experimental-unstable-prefixes.fixtures.js new file mode 100644 index 00000000000..08ce25f5b06 --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-align-experimental-unstable-prefixes.fixtures.js @@ -0,0 +1,74 @@ +export const fixtures = [ + { + name: 'Renames unstable hooks and handlers to internal', + source: ` +const clerk = useClerk(); +clerk.__unstable__updateProps({}); +window.__unstable__onAfterSetActive = () => {}; +const opts = { __unstable_invokeMiddlewareOnAuthStateChange: true }; +const handler = client['__unstable__onAfterResponse']; + `, + output: ` +const clerk = useClerk(); +clerk.__internal_updateProps({}); +window.__internal_onAfterSetActive = () => {}; +const opts = { __internal_invokeMiddlewareOnAuthStateChange: true }; +const handler = client["__internal_onAfterResponse"]; +`, + }, + { + name: 'Moves UI theme helpers to experimental path and renames identifiers', + source: ` +import { __experimental_createTheme, experimental__simple, Button } from '@clerk/ui'; + +const theme = __experimental_createTheme(); +const kind = experimental__simple; + `, + output: ` +import { Button } from '@clerk/ui'; + +import { createTheme, simple } from "@clerk/ui/themes/experimental"; + +const theme = createTheme(); +const kind = simple; +`, + }, + { + name: 'Moves UI theme helpers required from root to experimental path', + source: ` +const { __experimental_createTheme, experimental__simple, Card } = require('@clerk/ui'); + `, + output: ` +const { + Card +} = require('@clerk/ui'); + +const { + createTheme, + simple +} = require("@clerk/ui/themes/experimental"); +`, + }, + { + name: 'Moves chrome extension client creation to background path', + source: ` +import { __unstable__createClerkClient } from '@clerk/chrome-extension'; + +__unstable__createClerkClient(); + `, + output: ` +import { createClerkClient } from "@clerk/chrome-extension/background"; + +createClerkClient(); +`, + }, + { + name: 'Removes deprecated billing props from JSX', + source: ` +; + `, + output: ` +; +`, + }, +]; diff --git a/packages/upgrade/src/codemods/__tests__/transform-align-experimental-unstable-prefixes.test.js b/packages/upgrade/src/codemods/__tests__/transform-align-experimental-unstable-prefixes.test.js new file mode 100644 index 00000000000..e71a4eaf180 --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/transform-align-experimental-unstable-prefixes.test.js @@ -0,0 +1,13 @@ +import { applyTransform } from 'jscodeshift/dist/testUtils'; +import { describe, expect, it } from 'vitest'; + +import transformer from '../transform-align-experimental-unstable-prefixes.cjs'; +import { fixtures } from './__fixtures__/transform-align-experimental-unstable-prefixes.fixtures'; + +describe('transform-align-experimental-unstable-prefixes', () => { + it.each(fixtures)('$name', ({ source, output }) => { + const result = applyTransform(transformer, {}, { source }); + + expect(result).toEqual(output.trim()); + }); +}); diff --git a/packages/upgrade/src/codemods/transform-align-experimental-unstable-prefixes.cjs b/packages/upgrade/src/codemods/transform-align-experimental-unstable-prefixes.cjs new file mode 100644 index 00000000000..f7f419bf6b1 --- /dev/null +++ b/packages/upgrade/src/codemods/transform-align-experimental-unstable-prefixes.cjs @@ -0,0 +1,478 @@ +const SPECIFIC_RENAMES = { + experimental_createTheme: 'createTheme', + __experimental_createTheme: 'createTheme', + experimental__simple: 'simple', + __experimental_simple: 'simple', + __unstable__createClerkClient: 'createClerkClient', + __unstable_invokeMiddlewareOnAuthStateChange: '__internal_invokeMiddlewareOnAuthStateChange', + __unstable__environment: '__internal_environment', + __unstable__updateProps: '__internal_updateProps', + __unstable__setEnvironment: '__internal_setEnvironment', + __unstable__onBeforeRequest: '__internal_onBeforeRequest', + __unstable__onAfterResponse: '__internal_onAfterResponse', + __unstable__onBeforeSetActive: '__internal_onBeforeSetActive', + __unstable__onAfterSetActive: '__internal_onAfterSetActive', +}; + +const REMOVED_PROPS = new Set([ + '__unstable_manageBillingUrl', + '__unstable_manageBillingLabel', + '__unstable_manageBillingMembersLimit', + 'experimental__forceOauthFirst', +]); + +const UI_THEME_NAMES = new Set([ + 'createTheme', + 'simple', + 'experimental_createTheme', + '__experimental_createTheme', + 'experimental__simple', + '__experimental_simple', +]); +const UI_THEME_SOURCE = '@clerk/ui/themes/experimental'; +const UI_LEGACY_SOURCES = new Set(['@clerk/ui', '@clerk/ui/themes', UI_THEME_SOURCE]); + +const CHROME_CLIENT_NAMES = new Set(['__unstable__createClerkClient', 'createClerkClient']); +const CHROME_BACKGROUND_SOURCE = '@clerk/chrome-extension/background'; +const CHROME_LEGACY_SOURCE = '@clerk/chrome-extension'; + +module.exports = function transformAlignExperimentalUnstablePrefixes({ source }, { jscodeshift: j }) { + const root = j(source); + let dirty = false; + + const maybeRename = name => { + if (!name || REMOVED_PROPS.has(name)) { + return null; + } + return SPECIFIC_RENAMES[name] ?? null; + }; + + const renameIdentifier = node => { + const newName = maybeRename(node.name); + if (newName && newName !== node.name) { + node.name = newName; + dirty = true; + } + }; + + const renameLiteral = node => { + if (typeof node.value !== 'string') { + return; + } + const newName = maybeRename(node.value); + if (newName && newName !== node.value) { + node.value = newName; + dirty = true; + } + }; + + const getPropertyName = key => { + if (j.Identifier.check(key)) { + return key.name; + } + if (j.Literal.check(key)) { + return key.value; + } + if (j.StringLiteral && j.StringLiteral.check(key)) { + return key.value; + } + return null; + }; + + const renamePropertyKey = (key, computed = false) => { + if (REMOVED_PROPS.has(getPropertyName(key))) { + return null; + } + if (j.Identifier.check(key)) { + const newName = maybeRename(key.name); + if (newName && newName !== key.name) { + key.name = newName; + dirty = true; + } + return key; + } + if (!computed && (j.Literal.check(key) || (j.StringLiteral && j.StringLiteral.check(key)))) { + const newName = maybeRename(key.value); + if (newName && newName !== key.value) { + key.value = newName; + dirty = true; + } + return key; + } + return key; + }; + + const mergeImportSpecifiers = (targetImport, specifiers) => { + const existingKeys = new Set( + (targetImport.node.specifiers || []).map( + spec => `${spec.local ? spec.local.name : (spec.imported?.name ?? spec.imported?.value ?? '')}`, + ), + ); + specifiers.forEach(spec => { + const key = spec.local ? spec.local.name : spec.imported?.name; + if (!existingKeys.has(key)) { + targetImport.node.specifiers = targetImport.node.specifiers || []; + targetImport.node.specifiers.push(spec); + existingKeys.add(key); + dirty = true; + } + }); + }; + + root.find(j.ImportSpecifier).forEach(path => { + const imported = path.node.imported; + if (j.Identifier.check(imported)) { + const originalImportedName = imported.name; + renameIdentifier(imported); + if ( + (!path.node.local || path.node.local.name === originalImportedName) && + imported.name !== originalImportedName + ) { + path.node.local = j.identifier(imported.name); + dirty = true; + } + } + if (path.node.local) { + renameIdentifier(path.node.local); + } + }); + + root.find(j.ExportSpecifier).forEach(path => { + if (j.Identifier.check(path.node.exported)) { + renameIdentifier(path.node.exported); + } + if (j.Identifier.check(path.node.local)) { + renameIdentifier(path.node.local); + } + }); + + const handleMemberExpression = path => { + const { node } = path; + if (!node.computed && j.Identifier.check(node.property)) { + renameIdentifier(node.property); + } else if ( + node.computed && + (j.Literal.check(node.property) || (j.StringLiteral && j.StringLiteral.check(node.property))) + ) { + renameLiteral(node.property); + } + }; + + root.find(j.MemberExpression).forEach(handleMemberExpression); + if (j.OptionalMemberExpression) { + root.find(j.OptionalMemberExpression).forEach(handleMemberExpression); + } + + root.find(j.Property).forEach(path => { + const { node } = path; + const propName = getPropertyName(node.key); + if (propName && REMOVED_PROPS.has(propName) && !node.computed) { + path.prune(); + dirty = true; + return; + } + renamePropertyKey(node.key, node.computed); + if (j.Identifier.check(node.value)) { + renameIdentifier(node.value); + } + }); + + root.find(j.ObjectPattern).forEach(path => { + path.node.properties.forEach(prop => { + if (!prop) { + return; + } + const keyName = getPropertyName(prop.key); + if (keyName && REMOVED_PROPS.has(keyName) && !prop.computed) { + return; + } + if (prop.key) { + renamePropertyKey(prop.key, prop.computed); + } + if (prop.value && j.Identifier.check(prop.value)) { + renameIdentifier(prop.value); + } + }); + }); + + root.find(j.Identifier).forEach(path => { + renameIdentifier(path.node); + }); + + root.find(j.JSXOpeningElement).forEach(path => { + const attributes = path.node.attributes || []; + path.node.attributes = attributes.filter(attr => { + if (!j.JSXAttribute.check(attr) || !j.JSXIdentifier.check(attr.name)) { + return true; + } + const name = attr.name.name; + if (REMOVED_PROPS.has(name)) { + dirty = true; + return false; + } + const newName = maybeRename(name); + if (newName && newName !== name) { + attr.name.name = newName; + dirty = true; + } + return true; + }); + }); + + const normalizeUiThemeSpecifier = spec => { + if (!j.ImportSpecifier.check(spec)) { + return null; + } + const importedName = spec.imported?.name ?? spec.imported?.value; + if (!importedName || !UI_THEME_NAMES.has(importedName)) { + return null; + } + const newImportedName = maybeRename(importedName) || importedName; + const newImported = j.identifier(newImportedName); + const newLocal = + spec.local && spec.local.name !== importedName ? j.identifier(spec.local.name) : j.identifier(newImportedName); + return j.importSpecifier(newImported, newLocal.name === newImported.name ? null : newLocal); + }; + + root.find(j.ImportDeclaration).forEach(path => { + const source = path.node.source?.value; + if (!UI_LEGACY_SOURCES.has(source) && source !== CHROME_LEGACY_SOURCE) { + return; + } + + if (UI_LEGACY_SOURCES.has(source)) { + const specifiers = path.node.specifiers || []; + const moveSpecifiers = []; + const remainingSpecifiers = []; + + specifiers.forEach(spec => { + const normalized = normalizeUiThemeSpecifier(spec); + if (normalized) { + moveSpecifiers.push(normalized); + return; + } + remainingSpecifiers.push(spec); + }); + + if (source === UI_THEME_SOURCE) { + if (moveSpecifiers.length) { + path.node.specifiers = moveSpecifiers.concat( + remainingSpecifiers.filter(spec => !moveSpecifiers.some(m => m.imported.name === spec.imported?.name)), + ); + dirty = true; + } + return; + } + + if (moveSpecifiers.length) { + const targetImport = root.find(j.ImportDeclaration, { source: { value: UI_THEME_SOURCE } }).at(0); + if (targetImport.size() > 0) { + mergeImportSpecifiers(targetImport.get(), moveSpecifiers); + } else { + const newImport = j.importDeclaration(moveSpecifiers, j.literal(UI_THEME_SOURCE)); + j(path).insertAfter(newImport); + dirty = true; + } + + if (remainingSpecifiers.length) { + path.node.specifiers = remainingSpecifiers; + } else { + j(path).remove(); + } + } + } + + if (source === CHROME_LEGACY_SOURCE) { + const specifiers = path.node.specifiers || []; + const moveSpecifiers = []; + const remainingSpecifiers = []; + + specifiers.forEach(spec => { + if (!j.ImportSpecifier.check(spec)) { + remainingSpecifiers.push(spec); + return; + } + const importedName = spec.imported?.name ?? spec.imported?.value; + if (!CHROME_CLIENT_NAMES.has(importedName)) { + remainingSpecifiers.push(spec); + return; + } + const newImportedName = maybeRename(importedName) || importedName; + const newImported = j.identifier(newImportedName); + const newLocal = + spec.local && spec.local.name !== importedName + ? j.identifier(spec.local.name) + : j.identifier(newImportedName); + moveSpecifiers.push(j.importSpecifier(newImported, newLocal.name === newImported.name ? null : newLocal)); + }); + + if (moveSpecifiers.length) { + const targetImport = root.find(j.ImportDeclaration, { source: { value: CHROME_BACKGROUND_SOURCE } }).at(0); + if (targetImport.size() > 0) { + mergeImportSpecifiers(targetImport.get(), moveSpecifiers); + } else { + const newImport = j.importDeclaration(moveSpecifiers, j.literal(CHROME_BACKGROUND_SOURCE)); + j(path).insertAfter(newImport); + dirty = true; + } + + if (remainingSpecifiers.length) { + path.node.specifiers = remainingSpecifiers; + } else { + j(path).remove(); + } + } + } + }); + + root + .find(j.VariableDeclarator, { + init: { + callee: { name: 'require' }, + }, + }) + .filter(path => { + const arg = path.node.init.arguments?.[0]; + return ( + arg && + (j.Literal.check(arg) || (j.StringLiteral && j.StringLiteral.check(arg))) && + UI_LEGACY_SOURCES.has(arg.value) + ); + }) + .forEach(path => { + const id = path.node.id; + if (!j.ObjectPattern.check(id)) { + return; + } + + const moveProps = []; + const keepProps = []; + + id.properties.forEach(prop => { + if (!prop || !prop.key) { + return; + } + const keyName = getPropertyName(prop.key); + if (!keyName) { + keepProps.push(prop); + return; + } + if (!UI_THEME_NAMES.has(keyName)) { + keepProps.push(prop); + return; + } + const renamed = maybeRename(keyName) || keyName; + const keyIdentifier = j.identifier(renamed); + const valueIdentifier = + prop.value && j.Identifier.check(prop.value) && prop.value.name !== keyName + ? j.identifier(prop.value.name) + : j.identifier(renamed); + const newProp = j.property('init', keyIdentifier, valueIdentifier); + newProp.shorthand = keyIdentifier.name === valueIdentifier.name; + moveProps.push(newProp); + }); + + if (!moveProps.length) { + return; + } + + const parentDecl = path.parent.node; + const kind = parentDecl.kind || 'const'; + const newDeclarator = j.variableDeclarator( + j.objectPattern(moveProps), + j.callExpression(j.identifier('require'), [j.literal(UI_THEME_SOURCE)]), + ); + const newDeclaration = j.variableDeclaration(kind, [newDeclarator]); + j(path.parent).insertAfter(newDeclaration); + dirty = true; + + if (keepProps.length) { + id.properties = keepProps; + } else { + j(path).remove(); + } + }); + + root + .find(j.VariableDeclarator, { + init: { + callee: { name: 'require' }, + }, + }) + .filter(path => { + const arg = path.node.init.arguments?.[0]; + return ( + arg && + (j.Literal.check(arg) || (j.StringLiteral && j.StringLiteral.check(arg))) && + arg.value === CHROME_LEGACY_SOURCE + ); + }) + .forEach(path => { + const id = path.node.id; + if (!j.ObjectPattern.check(id)) { + return; + } + + const moveProps = []; + const keepProps = []; + + id.properties.forEach(prop => { + if (!prop || !prop.key) { + return; + } + const keyName = getPropertyName(prop.key); + if (!keyName || !CHROME_CLIENT_NAMES.has(keyName)) { + keepProps.push(prop); + return; + } + const renamed = maybeRename(keyName) || keyName; + const keyIdentifier = j.identifier(renamed); + const valueIdentifier = + prop.value && j.Identifier.check(prop.value) && prop.value.name !== keyName + ? j.identifier(prop.value.name) + : j.identifier(renamed); + const newProp = j.property('init', keyIdentifier, valueIdentifier); + newProp.shorthand = keyIdentifier.name === valueIdentifier.name; + moveProps.push(newProp); + }); + + if (!moveProps.length) { + return; + } + + const parentDecl = path.parent.node; + const kind = parentDecl.kind || 'const'; + const newDeclarator = j.variableDeclarator( + j.objectPattern(moveProps), + j.callExpression(j.identifier('require'), [j.literal(CHROME_BACKGROUND_SOURCE)]), + ); + const newDeclaration = j.variableDeclaration(kind, [newDeclarator]); + j(path.parent).insertAfter(newDeclaration); + dirty = true; + + if (keepProps.length) { + id.properties = keepProps; + } else { + j(path).remove(); + } + }); + + root.find(j.ObjectExpression).forEach(path => { + const props = path.node.properties || []; + path.node.properties = props.filter(prop => { + if (!prop || !prop.key) { + return true; + } + const propName = getPropertyName(prop.key); + if (propName && REMOVED_PROPS.has(propName) && !prop.computed) { + dirty = true; + return false; + } + return true; + }); + }); + + return dirty ? root.toSource() : undefined; +}; + +module.exports.parser = 'tsx'; From 2205f80a0a19d96a0aea122d2c338ef0b2551b36 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 4 Dec 2025 16:24:46 -0600 Subject: [PATCH 03/16] Adds codemod to transform appearance.layout -> appearance.options --- ...m-appearance-layout-to-options.fixtures.js | 11 +++ ...sform-appearance-layout-to-options.test.js | 13 ++++ ...transform-appearance-layout-to-options.cjs | 68 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 packages/upgrade/src/codemods/__tests__/__fixtures__/transform-appearance-layout-to-options.fixtures.js create mode 100644 packages/upgrade/src/codemods/__tests__/transform-appearance-layout-to-options.test.js create mode 100644 packages/upgrade/src/codemods/transform-appearance-layout-to-options.cjs diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-appearance-layout-to-options.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-appearance-layout-to-options.fixtures.js new file mode 100644 index 00000000000..4deb672568d --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-appearance-layout-to-options.fixtures.js @@ -0,0 +1,11 @@ +export const fixtures = [ + { + name: 'Renames layout inside JSX appearance prop', + source: ` + + `, + output: ` + + `, + }, +]; diff --git a/packages/upgrade/src/codemods/__tests__/transform-appearance-layout-to-options.test.js b/packages/upgrade/src/codemods/__tests__/transform-appearance-layout-to-options.test.js new file mode 100644 index 00000000000..7351a63a45e --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/transform-appearance-layout-to-options.test.js @@ -0,0 +1,13 @@ +import { applyTransform } from 'jscodeshift/dist/testUtils'; +import { describe, expect, it } from 'vitest'; + +import transformer from '../transform-appearance-layout-to-options.cjs'; +import { fixtures } from './__fixtures__/transform-appearance-layout-to-options.fixtures'; + +describe('transform-appearance-layout-to-options', () => { + it.each(fixtures)('$name', ({ source, output }) => { + const result = applyTransform(transformer, {}, { source }); + + expect(result).toEqual(output.trim()); + }); +}); diff --git a/packages/upgrade/src/codemods/transform-appearance-layout-to-options.cjs b/packages/upgrade/src/codemods/transform-appearance-layout-to-options.cjs new file mode 100644 index 00000000000..f0cf6a40a56 --- /dev/null +++ b/packages/upgrade/src/codemods/transform-appearance-layout-to-options.cjs @@ -0,0 +1,68 @@ +const isStringLiteral = node => + (node && node.type === 'Literal' && typeof node.value === 'string') || + (node && node.type === 'StringLiteral' && typeof node.value === 'string'); + +const getPropertyName = key => { + if (!key) { + return null; + } + if (key.type === 'Identifier') { + return key.name; + } + if (isStringLiteral(key)) { + return key.value; + } + return null; +}; + +module.exports = function transformAppearanceLayoutToOptions({ source }, { jscodeshift: j }) { + const root = j(source); + let dirty = false; + + const renameLayoutKey = prop => { + const keyName = getPropertyName(prop?.key); + if (!prop || keyName !== 'layout') { + return false; + } + if (prop.computed && !isStringLiteral(prop.key)) { + return false; + } + if (j.Identifier.check(prop.key)) { + prop.key.name = 'options'; + } else if (isStringLiteral(prop.key)) { + prop.key.value = 'options'; + } else { + prop.key = j.identifier('options'); + prop.computed = false; + } + return true; + }; + + root + .find(j.JSXAttribute, { + name: { name: 'appearance' }, + }) + .forEach(path => { + const { value } = path.node; + if (!value || !j.JSXExpressionContainer.check(value)) { + return; + } + const expression = value.expression; + if (j.ObjectExpression.check(expression)) { + let changed = false; + (expression.properties || []).forEach(prop => { + if (renameLayoutKey(prop)) { + changed = true; + } + }); + if (changed) { + dirty = true; + } + } + }); + + return dirty ? root.toSource() : undefined; +}; + +module.exports.parser = 'tsx'; + From e6b18d3625bca09f8ec27385ea1124eba941dc00 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 4 Dec 2025 20:22:55 -0600 Subject: [PATCH 04/16] Adds codemod to transform appearance prop changes --- ...ve-deprecated-appearance-props.fixtures.js | 67 ++++++++++ ...remove-deprecated-appearance-props.test.js | 13 ++ ...orm-remove-deprecated-appearance-props.cjs | 116 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-appearance-props.fixtures.js create mode 100644 packages/upgrade/src/codemods/__tests__/transform-remove-deprecated-appearance-props.test.js create mode 100644 packages/upgrade/src/codemods/transform-remove-deprecated-appearance-props.cjs diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-appearance-props.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-appearance-props.fixtures.js new file mode 100644 index 00000000000..315117aea21 --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-appearance-props.fixtures.js @@ -0,0 +1,67 @@ +export const fixtures = [ + { + name: 'Renames baseTheme to theme in JSX appearance', + source: ` + + `, + output: ` + + `, + }, + { + name: 'Renames baseTheme and variable keys when appearance object is referenced', + source: ` + const appearance = { + baseTheme: [dark, light], + variables: { + colorText: '#000', + colorTextSecondary: '#111', + colorInputText: '#222', + colorInputBackground: '#333', + colorTextOnPrimaryBackground: '#444', + spacingUnit: '1rem', + }, + }; + + + `, + output: ` + const appearance = { + theme: [dark, light], + variables: { + colorForeground: '#000', + colorMutedForeground: '#111', + colorInputForeground: '#222', + colorInput: '#333', + colorPrimaryForeground: '#444', + spacing: '1rem', + }, + }; + + + `, + }, + { + name: 'Handles string literal keys', + source: ` + const appearance = { + 'baseTheme': dark, + variables: { + 'colorText': '#000', + }, + }; + + + `, + output: ` + const appearance = { + "theme": dark, + variables: { + "colorForeground": '#000', + }, + }; + + + `, + }, +]; diff --git a/packages/upgrade/src/codemods/__tests__/transform-remove-deprecated-appearance-props.test.js b/packages/upgrade/src/codemods/__tests__/transform-remove-deprecated-appearance-props.test.js new file mode 100644 index 00000000000..1a02077754c --- /dev/null +++ b/packages/upgrade/src/codemods/__tests__/transform-remove-deprecated-appearance-props.test.js @@ -0,0 +1,13 @@ +import { applyTransform } from 'jscodeshift/dist/testUtils'; +import { describe, expect, it } from 'vitest'; + +import transformer from '../transform-remove-deprecated-appearance-props.cjs'; +import { fixtures } from './__fixtures__/transform-remove-deprecated-appearance-props.fixtures'; + +describe('transform-remove-deprecated-appearance-props', () => { + it.each(fixtures)('$name', ({ source, output }) => { + const result = applyTransform(transformer, {}, { source }); + + expect(result).toEqual(output.trim()); + }); +}); diff --git a/packages/upgrade/src/codemods/transform-remove-deprecated-appearance-props.cjs b/packages/upgrade/src/codemods/transform-remove-deprecated-appearance-props.cjs new file mode 100644 index 00000000000..848ad661153 --- /dev/null +++ b/packages/upgrade/src/codemods/transform-remove-deprecated-appearance-props.cjs @@ -0,0 +1,116 @@ +const VARIABLE_RENAMES = { + colorText: 'colorForeground', + colorTextSecondary: 'colorMutedForeground', + colorInputText: 'colorInputForeground', + colorInputBackground: 'colorInput', + colorTextOnPrimaryBackground: 'colorPrimaryForeground', + spacingUnit: 'spacing', +}; + +const isStringLiteral = node => + (node && node.type === 'Literal' && typeof node.value === 'string') || + (node && node.type === 'StringLiteral' && typeof node.value === 'string'); + +const getKeyName = key => { + if (!key) { + return null; + } + if (key.type === 'Identifier') { + return key.name; + } + if (isStringLiteral(key)) { + return key.value; + } + return null; +}; + +module.exports = function transformRemoveDeprecatedAppearanceProps({ source }, { jscodeshift: j }) { + const root = j(source); + let dirty = false; + + const renamePropertyKey = (prop, newName) => { + if (j.Identifier.check(prop.key)) { + prop.key.name = newName; + } else if (isStringLiteral(prop.key)) { + prop.key.value = newName; + } else { + prop.key = j.identifier(newName); + prop.computed = false; + } + dirty = true; + }; + + const maybeRenameBaseTheme = prop => { + if (!prop || prop.computed) { + return; + } + if (getKeyName(prop.key) === 'baseTheme') { + renamePropertyKey(prop, 'theme'); + } + }; + + const maybeRenameVariableKey = prop => { + if (!prop || prop.computed) { + return; + } + const keyName = getKeyName(prop.key); + const newName = VARIABLE_RENAMES[keyName]; + if (newName) { + renamePropertyKey(prop, newName); + } + }; + + const transformAppearanceObject = objExpr => { + const props = objExpr.properties || []; + props.forEach(prop => { + if (!prop || !prop.key) { + return; + } + maybeRenameBaseTheme(prop); + if (getKeyName(prop.key) === 'variables' && j.ObjectExpression.check(prop.value)) { + (prop.value.properties || []).forEach(maybeRenameVariableKey); + } + }); + }; + + const findObjectForIdentifier = identifier => { + if (!identifier || !j.Identifier.check(identifier)) { + return null; + } + const name = identifier.name; + const decl = root + .find(j.VariableDeclarator, { + id: { type: 'Identifier', name }, + }) + .filter(p => j.ObjectExpression.check(p.node.init)) + .at(0); + if (decl.size() === 0) { + return null; + } + return decl.get().node.init; + }; + + root + .find(j.JSXAttribute, { + name: { name: 'appearance' }, + }) + .forEach(path => { + const { value } = path.node; + if (!value || !j.JSXExpressionContainer.check(value)) { + return; + } + const expr = value.expression; + if (j.ObjectExpression.check(expr)) { + transformAppearanceObject(expr); + } else if (j.Identifier.check(expr)) { + const obj = findObjectForIdentifier(expr); + if (obj) { + transformAppearanceObject(obj); + } + } + }); + + return dirty ? root.toSource() : undefined; +}; + +module.exports.parser = 'tsx'; From a2f1e49debc603806233a6cbf9016d2f5d34d7d2 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 5 Dec 2025 12:56:10 -0600 Subject: [PATCH 05/16] refactor upgrade CLI to not use ink --- packages/upgrade/package.json | 7 - .../expo-old-package/package-lock.json | 5 + .../fixtures/expo-old-package/package.json | 10 + .../fixtures/expo-old-package/src/App.tsx | 14 + .../__tests__/fixtures/nextjs-v6/package.json | 9 + .../fixtures/nextjs-v6/pnpm-lock.yaml | 2 + .../__tests__/fixtures/nextjs-v6/src/app.tsx | 17 + .../__tests__/fixtures/nextjs-v7/package.json | 9 + .../fixtures/nextjs-v7/pnpm-lock.yaml | 2 + .../__tests__/fixtures/nextjs-v7/src/app.tsx | 16 + .../__tests__/fixtures/no-clerk/package.json | 7 + .../__tests__/fixtures/react-v6/package.json | 8 + .../__tests__/fixtures/react-v6/src/App.tsx | 19 + .../src/__tests__/fixtures/react-v6/yarn.lock | 2 + .../src/__tests__/helpers/create-fixture.js | 60 +++ .../src/__tests__/helpers/mock-render.js | 134 ++++++ .../src/__tests__/integration/cli.test.js | 234 ++++++++++ .../src/__tests__/integration/config.test.js | 108 +++++ .../__tests__/integration/detect-sdk.test.js | 125 ++++++ .../src/__tests__/integration/runner.test.js | 95 ++++ packages/upgrade/src/app.js | 285 ------------ packages/upgrade/src/cli.js | 221 ++++++++-- .../transform-remove-deprecated-props.cjs | 6 +- packages/upgrade/src/components/Codemod.js | 179 -------- packages/upgrade/src/components/Command.js | 65 --- packages/upgrade/src/components/Header.js | 14 - .../upgrade/src/components/SDKWorkflow.js | 406 ------------------ packages/upgrade/src/components/Scan.js | 206 --------- packages/upgrade/src/components/UpgradeSDK.js | 128 ------ packages/upgrade/src/config.js | 125 ++++++ packages/upgrade/src/render.js | 242 +++++++++++ packages/upgrade/src/runner.js | 141 ++++++ packages/upgrade/src/util/detect-sdk.js | 116 +++++ packages/upgrade/src/util/expandable-list.js | 227 ---------- .../upgrade/src/util/get-clerk-version.js | 25 -- packages/upgrade/src/util/guess-framework.js | 59 --- packages/upgrade/src/util/package-manager.js | 105 +++++ .../changes/clerk-expo-package-rename.md | 23 + .../changes/clerk-react-package-rename.md | 23 + .../changes/deprecated-appearance-props.md | 14 + packages/upgrade/src/versions/core-3/index.js | 23 + 41 files changed, 1874 insertions(+), 1642 deletions(-) create mode 100644 packages/upgrade/src/__tests__/fixtures/expo-old-package/package-lock.json create mode 100644 packages/upgrade/src/__tests__/fixtures/expo-old-package/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/expo-old-package/src/App.tsx create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-v6/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-v6/pnpm-lock.yaml create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-v6/src/app.tsx create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-v7/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-v7/pnpm-lock.yaml create mode 100644 packages/upgrade/src/__tests__/fixtures/nextjs-v7/src/app.tsx create mode 100644 packages/upgrade/src/__tests__/fixtures/no-clerk/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/react-v6/package.json create mode 100644 packages/upgrade/src/__tests__/fixtures/react-v6/src/App.tsx create mode 100644 packages/upgrade/src/__tests__/fixtures/react-v6/yarn.lock create mode 100644 packages/upgrade/src/__tests__/helpers/create-fixture.js create mode 100644 packages/upgrade/src/__tests__/helpers/mock-render.js create mode 100644 packages/upgrade/src/__tests__/integration/cli.test.js create mode 100644 packages/upgrade/src/__tests__/integration/config.test.js create mode 100644 packages/upgrade/src/__tests__/integration/detect-sdk.test.js create mode 100644 packages/upgrade/src/__tests__/integration/runner.test.js delete mode 100644 packages/upgrade/src/app.js delete mode 100644 packages/upgrade/src/components/Codemod.js delete mode 100644 packages/upgrade/src/components/Command.js delete mode 100644 packages/upgrade/src/components/Header.js delete mode 100644 packages/upgrade/src/components/SDKWorkflow.js delete mode 100644 packages/upgrade/src/components/Scan.js delete mode 100644 packages/upgrade/src/components/UpgradeSDK.js create mode 100644 packages/upgrade/src/config.js create mode 100644 packages/upgrade/src/render.js create mode 100644 packages/upgrade/src/runner.js create mode 100644 packages/upgrade/src/util/detect-sdk.js delete mode 100644 packages/upgrade/src/util/expandable-list.js delete mode 100644 packages/upgrade/src/util/get-clerk-version.js delete mode 100644 packages/upgrade/src/util/guess-framework.js create mode 100644 packages/upgrade/src/util/package-manager.js create mode 100644 packages/upgrade/src/versions/core-3/changes/clerk-expo-package-rename.md create mode 100644 packages/upgrade/src/versions/core-3/changes/clerk-react-package-rename.md create mode 100644 packages/upgrade/src/versions/core-3/changes/deprecated-appearance-props.md create mode 100644 packages/upgrade/src/versions/core-3/index.js diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index 6ab544e210e..7af7e5f3001 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -32,21 +32,14 @@ ] }, "dependencies": { - "@inkjs/ui": "^2.0.0", - "@jescalan/ink-markdown": "^2.0.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..522fe1722d3 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/expo-old-package/src/App.tsx @@ -0,0 +1,14 @@ +import { ClerkProvider, useAuth } from '@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..13f50a0e9d2 --- /dev/null +++ b/packages/upgrade/src/__tests__/fixtures/nextjs-v6/src/app.tsx @@ -0,0 +1,17 @@ +import { ClerkProvider, useAuth, useUser } from '@clerk/nextjs'; +import { dark } from '@clerk/nextjs/themes'; + +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__/helpers/mock-render.js b/packages/upgrade/src/__tests__/helpers/mock-render.js new file mode 100644 index 00000000000..21f1a03df60 --- /dev/null +++ b/packages/upgrade/src/__tests__/helpers/mock-render.js @@ -0,0 +1,134 @@ +import { vi } from 'vitest'; + +export function createRenderMock() { + const output = []; + const prompts = []; + let promptIndex = 0; + + const mock = { + output, + prompts, + + setPromptResponses(responses) { + prompts.push(...responses); + promptIndex = 0; + }, + + getOutput() { + return output.join('\n'); + }, + + clear() { + output.length = 0; + prompts.length = 0; + promptIndex = 0; + }, + + renderHeader: vi.fn(() => { + output.push('[HEADER] Clerk Upgrade'); + }), + + renderText: vi.fn((message, color) => { + output.push(`[TEXT:${color || 'default'}] ${message}`); + }), + + renderSuccess: vi.fn(message => { + output.push(`[SUCCESS] ${message}`); + }), + + renderError: vi.fn(message => { + output.push(`[ERROR] ${message}`); + }), + + renderWarning: vi.fn(message => { + output.push(`[WARNING] ${message}`); + }), + + renderNewline: vi.fn(() => { + output.push(''); + }), + + renderConfig: vi.fn(config => { + output.push(`[CONFIG] SDK: ${config.sdk}, Version: ${config.currentVersion}, Dir: ${config.dir}`); + }), + + promptConfirm: vi.fn(async message => { + output.push(`[PROMPT:confirm] ${message}`); + const response = prompts[promptIndex++]; + return response ?? true; + }), + + promptSelect: vi.fn(async (message, options) => { + output.push(`[PROMPT:select] ${message}`); + const response = prompts[promptIndex++]; + return response ?? options[0]?.value; + }), + + promptText: vi.fn(async (message, defaultValue) => { + output.push(`[PROMPT:text] ${message}`); + const response = prompts[promptIndex++]; + return response ?? defaultValue; + }), + + createSpinner: vi.fn(label => { + output.push(`[SPINNER:start] ${label}`); + return { + update: vi.fn(newLabel => { + output.push(`[SPINNER:update] ${newLabel}`); + }), + stop: vi.fn(() => { + output.push('[SPINNER:stop]'); + }), + success: vi.fn(message => { + output.push(`[SPINNER:success] ${message}`); + }), + error: vi.fn(message => { + output.push(`[SPINNER:error] ${message}`); + }), + }; + }), + + renderCodemodResults: vi.fn((transform, result) => { + output.push(`[CODEMOD:result] ${transform} - ok: ${result.ok}, error: ${result.error}, skip: ${result.skip}`); + }), + + renderManualInterventionSummary: vi.fn(stats => { + if (stats) { + output.push(`[MANUAL] Stats: ${JSON.stringify(stats)}`); + } + }), + + renderScanResults: vi.fn((results, docsUrl) => { + output.push(`[SCAN:results] Found ${results.length} issue(s)`); + for (const result of results) { + output.push(` - ${result.title}: ${result.instances.length} instance(s)`); + } + }), + + renderComplete: vi.fn(sdk => { + output.push(`[COMPLETE] Upgrade complete for @clerk/${sdk}`); + }), + }; + + return mock; +} + +export function mockRenderModule(renderMock) { + return { + renderHeader: renderMock.renderHeader, + renderText: renderMock.renderText, + renderSuccess: renderMock.renderSuccess, + renderError: renderMock.renderError, + renderWarning: renderMock.renderWarning, + renderNewline: renderMock.renderNewline, + renderConfig: renderMock.renderConfig, + promptConfirm: renderMock.promptConfirm, + promptSelect: renderMock.promptSelect, + promptText: renderMock.promptText, + createSpinner: renderMock.createSpinner, + renderCodemodResults: renderMock.renderCodemodResults, + renderManualInterventionSummary: renderMock.renderManualInterventionSummary, + renderScanResults: renderMock.renderScanResults, + renderComplete: renderMock.renderComplete, + }; +} 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..85c7468197e --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/cli.test.js @@ -0,0 +1,234 @@ +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'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// CLI uses JSX which requires babel transpilation - use built dist version +const CLI_PATH = path.resolve(__dirname, '../../../dist/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(); + }); + + child.stderr.on('data', data => { + stderr += 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.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'], { 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'], { 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'], { 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); + }); + + it('works with explicit --sdk flag when SDK cannot be detected', async () => { + const dir = getFixturePath('no-clerk'); + const result = await runCli(['--dir', dir, '--sdk', 'nextjs', '--dry-run'], { timeout: 15000 }); + + expect(result.stdout).toContain('@clerk/nextjs'); + }); + }); + + describe('--sdk flag', () => { + it('accepts explicit SDK specification', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--sdk', 'nextjs', '--dry-run'], { 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'], { 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'], { timeout: 15000 }); + + expect(result.stdout).toContain('Dry run'); + expect(result.stdout).toContain('Would upgrade'); + }); + + 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'], { 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'], { timeout: 15000 }); + + expect(result.stdout).toMatch(/v6|version.*6/i); + }); + + it('shows upgrade path in output', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--dry-run'], { timeout: 15000 }); + + expect(result.stdout).toMatch(/v6.*v7|6.*→.*7/); + }); + }); + + describe('Package Manager Detection', () => { + it('detects pnpm from fixture', async () => { + const dir = getFixturePath('nextjs-v6'); + const result = await runCli(['--dir', dir, '--dry-run'], { 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'], { 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'], { 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'], { 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'], { 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'], { timeout: 15000 }); + + expect(result.stdout).toContain('codemod'); + }); + }); +}); 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..ce8707a2b12 --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/config.test.js @@ -0,0 +1,108 @@ +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'); + }); + + it('returns config for unknown version (defaults to upgrade)', async () => { + const config = await loadConfig('nextjs', null); + + expect(config).not.toBeNull(); + expect(config.versionStatus).toBe('unknown'); + }); +}); + +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..cd2fed59397 --- /dev/null +++ b/packages/upgrade/src/__tests__/integration/runner.test.js @@ -0,0 +1,95 @@ +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', () => ({ + createSpinner: vi.fn(() => ({ + update: vi.fn(), + stop: vi.fn(), + success: vi.fn(), + error: vi.fn(), + })), + promptText: vi.fn(async (msg, defaultValue) => defaultValue), + renderCodemodResults: vi.fn(), + renderManualInterventionSummary: 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(Array.isArray(results)).toBe(true); + }); + + 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(Array.isArray(results)).toBe(true); + }); +}); + +describe('runScans with theme imports', () => { + let fixture; + + beforeEach(() => { + fixture = createTempFixture('nextjs-v6'); + }); + + afterEach(() => { + fixture?.cleanup(); + }); + + it('detects theme imports from @clerk/nextjs/themes', async () => { + const config = await loadConfig('nextjs', 6); + const options = { + dir: fixture.path, + ignore: [], + }; + + const results = await runScans(config, 'nextjs', options); + const themeChange = results.find(r => r.title?.includes('Theme') || r.docsAnchor?.includes('theme')); + + if (themeChange) { + expect(themeChange.instances.length).toBeGreaterThan(0); + } + }); +}); 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 - -