From 4ceb050061c3b797cf0bc9d10359ee481e111a74 Mon Sep 17 00:00:00 2001 From: Jiwon Choi Date: Wed, 25 Sep 2024 08:23:29 +0900 Subject: [PATCH] refactor(next-codemod): migrate to commander and prompts (#70409) ### Why? Prerequisite of `@next/codemod upgrade`. Current status of next-codemod is difficult to extend another commands as it relies on [meow](https://www.npmjs.com/package/meow) which requires to handle args and flags manually. For consistency with `next` and `create-next-app`, migrated to `commander` and `prompts`. https://github.com/user-attachments/assets/8d2e53be-4b7d-4755-9a3f-c73df1f9ef7e No behavioral changes **except the help message**. --- .vscode/settings.json | 2 + packages/next-codemod/bin/cli.ts | 270 ---------------------- packages/next-codemod/bin/next-codemod.ts | 47 +++- packages/next-codemod/bin/transform.ts | 133 +++++++++++ packages/next-codemod/lib/utils.ts | 104 +++++++++ packages/next-codemod/package.json | 3 +- packages/next-codemod/tsconfig.json | 2 +- pnpm-lock.yaml | 34 +-- 8 files changed, 287 insertions(+), 308 deletions(-) delete mode 100644 packages/next-codemod/bin/cli.ts create mode 100644 packages/next-codemod/bin/transform.ts create mode 100644 packages/next-codemod/lib/utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index f0fc53bd6cd74..7b4b1e6a9bfb5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,6 +57,8 @@ "RUST_BACKTRACE": "0" }, "cSpell.words": [ + "codemod", + "codemods", "Destructuring", "Entrypoints", "jscodeshift", diff --git a/packages/next-codemod/bin/cli.ts b/packages/next-codemod/bin/cli.ts deleted file mode 100644 index c93eb018fb61b..0000000000000 --- a/packages/next-codemod/bin/cli.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Copyright 2015-present, Facebook, Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -// Based on https://github.com/reactjs/react-codemod/blob/dd8671c9a470a2c342b221ec903c574cf31e9f57/bin/cli.js -// @next/codemod optional-name-of-transform optional/path/to/src [...options] - -import globby from 'globby' -import inquirer from 'inquirer' -import meow from 'meow' -import path from 'path' -import execa from 'execa' -import { yellow } from 'picocolors' -import isGitClean from 'is-git-clean' -import { installPackage, uninstallPackage } from '../lib/handle-package' -import { runUpgrade } from './upgrade' - -export const jscodeshiftExecutable = require.resolve('.bin/jscodeshift') -export const transformerDirectory = path.join(__dirname, '../', 'transforms') - -export function checkGitStatus(force) { - let clean = false - let errorMessage = 'Unable to determine if git directory is clean' - try { - clean = isGitClean.sync(process.cwd()) - errorMessage = 'Git directory is not clean' - } catch (err) { - if (err && err.stderr && err.stderr.includes('Not a git repository')) { - clean = true - } - } - - if (!clean) { - if (force) { - console.log(`WARNING: ${errorMessage}. Forcibly continuing.`) - } else { - console.log('Thank you for using @next/codemod!') - console.log( - yellow( - '\nBut before we continue, please stash or commit your git changes.' - ) - ) - console.log( - '\nYou may use the --force flag to override this safety check.' - ) - process.exit(1) - } - } -} - -export function runTransform({ files, flags, transformer }) { - const transformerPath = path.join(transformerDirectory, `${transformer}.js`) - - if (transformer === 'cra-to-next') { - // cra-to-next transform doesn't use jscodeshift directly - return require(transformerPath).default(files, flags) - } - - let args = [] - - const { dry, print, runInBand } = flags - - if (dry) { - args.push('--dry') - } - if (print) { - args.push('--print') - } - if (runInBand) { - args.push('--run-in-band') - } - - args.push('--verbose=2') - - args.push('--ignore-pattern=**/node_modules/**') - args.push('--ignore-pattern=**/.next/**') - - args.push('--extensions=tsx,ts,jsx,js') - - args = args.concat(['--transform', transformerPath]) - - if (flags.jscodeshift) { - args = args.concat(flags.jscodeshift) - } - - args = args.concat(files) - - console.log(`Executing command: jscodeshift ${args.join(' ')}`) - - const result = execa.sync(jscodeshiftExecutable, args, { - stdio: 'inherit', - stripFinalNewline: false, - }) - - if (result.failed) { - throw new Error(`jscodeshift exited with code ${result.exitCode}`) - } - - if (!dry && transformer === 'built-in-next-font') { - console.log('Uninstalling `@next/font`') - try { - uninstallPackage('@next/font') - } catch { - console.error( - "Couldn't uninstall `@next/font`, please uninstall it manually" - ) - } - } - - if (!dry && transformer === 'next-request-geo-ip') { - console.log('Installing `@vercel/functions`...') - installPackage('@vercel/functions') - } -} - -const TRANSFORMER_INQUIRER_CHOICES = [ - { - name: 'name-default-component: Transforms anonymous components into named components to make sure they work with Fast Refresh', - value: 'name-default-component', - }, - { - name: 'add-missing-react-import: Transforms files that do not import `React` to include the import in order for the new React JSX transform', - value: 'add-missing-react-import', - }, - { - name: 'withamp-to-config: Transforms the withAmp HOC into Next.js 9 page configuration', - value: 'withamp-to-config', - }, - { - name: 'url-to-withrouter: Transforms the deprecated automatically injected url property on top level pages to using withRouter', - value: 'url-to-withrouter', - }, - { - name: 'cra-to-next (experimental): automatically migrates a Create React App project to Next.js', - value: 'cra-to-next', - }, - { - name: 'new-link: Ensures your usage is backwards compatible.', - value: 'new-link', - }, - { - name: 'next-og-import: Transforms imports from `next/server` to `next/og` for usage of Dynamic OG Image Generation.', - value: 'next-og-import', - }, - { - name: 'metadata-to-viewport-export: Migrates certain viewport related metadata from the `metadata` export to a new `viewport` export.', - value: 'metadata-to-viewport-export', - }, - { - name: 'next-dynamic-access-named-export: Transforms dynamic imports that return the named export itself to a module like object.', - value: 'next-dynamic-access-named-export', - }, - { - name: 'next-image-to-legacy-image: safely migrate Next.js 10, 11, 12 applications importing `next/image` to the renamed `next/legacy/image` import in Next.js 13', - value: 'next-image-to-legacy-image', - }, - { - name: 'next-image-experimental (experimental): dangerously migrates from `next/legacy/image` to the new `next/image` by adding inline styles and removing unused props', - value: 'next-image-experimental', - }, - { - name: 'built-in-next-font: Uninstall `@next/font` and transform imports to `next/font`', - value: 'built-in-next-font', - }, - { - name: 'next-async-request-api: Transforms usage of Next.js async Request APIs', - value: 'next-async-request-api', - }, - { - name: 'next-request-geo-ip: Install `@vercel/functions` to replace `geo` and `ip` properties on `NextRequest`', - value: 'next-request-geo-ip', - }, -] - -function expandFilePathsIfNeeded(filesBeforeExpansion) { - const shouldExpandFiles = filesBeforeExpansion.some((file) => - file.includes('*') - ) - return shouldExpandFiles - ? globby.sync(filesBeforeExpansion) - : filesBeforeExpansion -} - -export function run() { - const cli = meow({ - description: 'Codemods for updating Next.js apps.', - help: ` - Usage - $ npx @next/codemod <...options> - transform One of the choices from https://github.com/vercel/next.js/tree/canary/packages/next-codemod - path Files or directory to transform. Can be a glob like pages/**.js - Options - --force Bypass Git safety checks and forcibly run codemods - --dry Dry run (no changes are made to files) - --print Print transformed files to your terminal - --jscodeshift (Advanced) Pass options directly to jscodeshift - `, - flags: { - boolean: ['force', 'dry', 'print', 'help'], - string: ['_'], - alias: { - h: 'help', - }, - }, - } as meow.Options) - - if (!cli.flags.dry) { - checkGitStatus(cli.flags.force) - } - - const isUpgrade = cli.input[0] === 'upgrade' || cli.input[0] === 'up' - - if (isUpgrade) { - return runUpgrade().catch(console.error) - } - - if ( - cli.input[0] && - !TRANSFORMER_INQUIRER_CHOICES.find((x) => x.value === cli.input[0]) - ) { - console.error('Invalid transform choice, pick one of:') - console.error( - TRANSFORMER_INQUIRER_CHOICES.map((x) => '- ' + x.value).join('\n') - ) - process.exit(1) - } - - inquirer - .prompt([ - { - type: 'input', - name: 'files', - message: 'On which files or directory should the codemods be applied?', - when: !cli.input[1], - default: '.', - // validate: () => - filter: (files) => files.trim(), - }, - { - type: 'list', - name: 'transformer', - message: 'Which transform would you like to apply?', - when: !cli.input[0], - pageSize: TRANSFORMER_INQUIRER_CHOICES.length, - choices: TRANSFORMER_INQUIRER_CHOICES, - }, - ]) - .then((answers) => { - const { files, transformer } = answers - - const filesBeforeExpansion = cli.input[1] || files - const filesExpanded = expandFilePathsIfNeeded([filesBeforeExpansion]) - - const selectedTransformer = cli.input[0] || transformer - - if (!filesExpanded.length) { - console.log(`No files found matching ${filesBeforeExpansion.join(' ')}`) - return null - } - - return runTransform({ - files: filesExpanded, - flags: cli.flags, - transformer: selectedTransformer, - }) - }) -} diff --git a/packages/next-codemod/bin/next-codemod.ts b/packages/next-codemod/bin/next-codemod.ts index 8268a4de769b0..d47e596a481c5 100644 --- a/packages/next-codemod/bin/next-codemod.ts +++ b/packages/next-codemod/bin/next-codemod.ts @@ -1,5 +1,4 @@ #!/usr/bin/env node - /** * Copyright 2015-present, Facebook, Inc. * @@ -7,7 +6,47 @@ * LICENSE file in the root directory of this source tree. * */ -// Based on https://github.com/reactjs/react-codemod/blob/dd8671c9a470a2c342b221ec903c574cf31e9f57/bin/react-codemod.js -// next-codemod optional-name-of-transform optional/path/to/src [...options] +// Based on https://github.com/reactjs/react-codemod/blob/dd8671c9a470a2c342b221ec903c574cf31e9f57/bin/cli.js +// @next/codemod optional-name-of-transform optional/path/to/src [...options] + +import { Command } from 'commander' +import { runUpgrade } from './upgrade' +import { runTransform } from './transform' + +const packageJson = require('../package.json') +const program = new Command(packageJson.name) + .description('Codemods for updating Next.js apps.') + .version( + packageJson.version, + '-v, --version', + 'Output the current version of @next/codemod.' + ) + .argument( + '[codemod]', + 'Codemod slug to run. See "https://github.com/vercel/next.js/tree/canary/packages/next-codemod".' + ) + .argument( + '[source]', + 'Path to source files or directory to transform including glob patterns.' + ) + .usage('[codemod] [source] [options]') + .helpOption('-h, --help', 'Display this help message.') + .option('-f, --force', 'Bypass Git safety checks and forcibly run codemods') + .option('-d, --dry', 'Dry run (no changes are made to files)') + .option('-p, --print', 'Print transformed files to your terminal') + .option( + '-j, --jscodeshift', + '(Advanced) Pass options directly to jscodeshift' + ) + .action(runTransform) + .allowUnknownOption() + +program + .command('upgrade') + .description( + 'Upgrade Next.js apps to desired versions with a single command.' + ) + .usage('[options]') + .action(runUpgrade) -require('./cli').run() +program.parse(process.argv) diff --git a/packages/next-codemod/bin/transform.ts b/packages/next-codemod/bin/transform.ts new file mode 100644 index 0000000000000..4ef9f14017c87 --- /dev/null +++ b/packages/next-codemod/bin/transform.ts @@ -0,0 +1,133 @@ +import execa from 'execa' +import globby from 'globby' +import prompts from 'prompts' +import { join } from 'node:path' +import { installPackage, uninstallPackage } from '../lib/handle-package' +import { checkGitStatus, TRANSFORMER_INQUIRER_CHOICES } from '../lib/utils' + +function expandFilePathsIfNeeded(filesBeforeExpansion) { + const shouldExpandFiles = filesBeforeExpansion.some((file) => + file.includes('*') + ) + return shouldExpandFiles + ? globby.sync(filesBeforeExpansion) + : filesBeforeExpansion +} + +export const jscodeshiftExecutable = require.resolve('.bin/jscodeshift') +export const transformerDirectory = join(__dirname, '../', 'transforms') + +export async function runTransform( + transform: string, + path: string, + options: any +) { + let transformer = transform + let directory = path + + if (!options.dry) { + checkGitStatus(options.force) + } + + if ( + transform && + !TRANSFORMER_INQUIRER_CHOICES.find((x) => x.value === transform) + ) { + console.error('Invalid transform choice, pick one of:') + console.error( + TRANSFORMER_INQUIRER_CHOICES.map((x) => '- ' + x.value).join('\n') + ) + process.exit(1) + } + + if (!path) { + const res = await prompts({ + type: 'text', + name: 'path', + message: 'On which files or directory should the codemods be applied?', + default: '.', + }) + + directory = res.path + } + if (!transform) { + const res = await prompts({ + type: 'select', + name: 'transformer', + message: 'Which transform would you like to apply?', + choices: TRANSFORMER_INQUIRER_CHOICES, + }) + + transformer = res.transformer + } + + const filesExpanded = expandFilePathsIfNeeded([directory]) + + if (!filesExpanded.length) { + console.log(`No files found matching "${directory}"`) + return null + } + + const transformerPath = join(transformerDirectory, `${transformer}.js`) + + if (transformer === 'cra-to-next') { + // cra-to-next transform doesn't use jscodeshift directly + return require(transformerPath).default(filesExpanded, options) + } + + let args = [] + + const { dry, print, runInBand, jscodeshift } = options + + if (dry) { + args.push('--dry') + } + if (print) { + args.push('--print') + } + if (runInBand) { + args.push('--run-in-band') + } + + args.push('--verbose=2') + + args.push('--ignore-pattern=**/node_modules/**') + args.push('--ignore-pattern=**/.next/**') + + args.push('--extensions=tsx,ts,jsx,js') + + args = args.concat(['--transform', transformerPath]) + + if (jscodeshift) { + args = args.concat(jscodeshift) + } + + args = args.concat(filesExpanded) + + console.log(`Executing command: jscodeshift ${args.join(' ')}`) + + const result = execa.sync(jscodeshiftExecutable, args, { + stdio: 'inherit', + stripFinalNewline: false, + }) + + if (result.failed) { + throw new Error(`jscodeshift exited with code ${result.exitCode}`) + } + + if (!dry && transformer === 'built-in-next-font') { + console.log('Uninstalling `@next/font`') + try { + uninstallPackage('@next/font') + } catch { + console.error( + "Couldn't uninstall `@next/font`, please uninstall it manually" + ) + } + } + + if (!dry && transformer === 'next-request-geo-ip') { + console.log('Installing `@vercel/functions`...') + installPackage('@vercel/functions') + } +} diff --git a/packages/next-codemod/lib/utils.ts b/packages/next-codemod/lib/utils.ts new file mode 100644 index 0000000000000..38f5bf4e86aa7 --- /dev/null +++ b/packages/next-codemod/lib/utils.ts @@ -0,0 +1,104 @@ +import { yellow } from 'picocolors' +import isGitClean from 'is-git-clean' + +export function checkGitStatus(force) { + let clean = false + let errorMessage = 'Unable to determine if git directory is clean' + try { + clean = isGitClean.sync(process.cwd()) + errorMessage = 'Git directory is not clean' + } catch (err) { + if (err && err.stderr && err.stderr.includes('Not a git repository')) { + clean = true + } + } + + if (!clean) { + if (force) { + console.log(`WARNING: ${errorMessage}. Forcibly continuing.`) + } else { + console.log('Thank you for using @next/codemod!') + console.log( + yellow( + '\nBut before we continue, please stash or commit your git changes.' + ) + ) + console.log( + '\nYou may use the --force flag to override this safety check.' + ) + process.exit(1) + } + } +} + +export const TRANSFORMER_INQUIRER_CHOICES = [ + { + title: + 'name-default-component: Transforms anonymous components into named components to make sure they work with Fast Refresh', + value: 'name-default-component', + }, + { + title: + 'add-missing-react-import: Transforms files that do not import `React` to include the import in order for the new React JSX transform', + value: 'add-missing-react-import', + }, + { + title: + 'withamp-to-config: Transforms the withAmp HOC into Next.js 9 page configuration', + value: 'withamp-to-config', + }, + { + title: + 'url-to-withrouter: Transforms the deprecated automatically injected url property on top level pages to using withRouter', + value: 'url-to-withrouter', + }, + { + title: + 'cra-to-next (experimental): automatically migrates a Create React App project to Next.js', + value: 'cra-to-next', + }, + { + title: 'new-link: Ensures your usage is backwards compatible.', + value: 'new-link', + }, + { + title: + 'next-og-import: Transforms imports from `next/server` to `next/og` for usage of Dynamic OG Image Generation.', + value: 'next-og-import', + }, + { + title: + 'metadata-to-viewport-export: Migrates certain viewport related metadata from the `metadata` export to a new `viewport` export.', + value: 'metadata-to-viewport-export', + }, + { + title: + 'next-dynamic-access-named-export: Transforms dynamic imports that return the named export itself to a module like object.', + value: 'next-dynamic-access-named-export', + }, + { + title: + 'next-image-to-legacy-image: safely migrate Next.js 10, 11, 12 applications importing `next/image` to the renamed `next/legacy/image` import in Next.js 13', + value: 'next-image-to-legacy-image', + }, + { + title: + 'next-image-experimental (experimental): dangerously migrates from `next/legacy/image` to the new `next/image` by adding inline styles and removing unused props', + value: 'next-image-experimental', + }, + { + title: + 'built-in-next-font: Uninstall `@next/font` and transform imports to `next/font`', + value: 'built-in-next-font', + }, + { + title: + 'next-async-request-api: Transforms usage of Next.js async Request APIs', + value: 'next-async-request-api', + }, + { + title: + 'next-request-geo-ip: Install `@vercel/functions` to replace `geo` and `ip` properties on `NextRequest`', + value: 'next-request-geo-ip', + }, +] diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 39eb6be78a3b7..431e1abe8cab5 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -10,13 +10,12 @@ "dependencies": { "chalk": "4.1.2", "cheerio": "1.0.0-rc.9", + "commander": "12.1.0", "compare-versions": "6.1.1", "execa": "4.0.3", "globby": "11.0.1", - "inquirer": "7.3.3", "is-git-clean": "1.1.0", "jscodeshift": "17.0.0", - "meow": "7.0.1", "picocolors": "1.0.0", "prompts": "2.4.2" }, diff --git a/packages/next-codemod/tsconfig.json b/packages/next-codemod/tsconfig.json index 8f5a4bf31b1e1..f54bd265b1843 100644 --- a/packages/next-codemod/tsconfig.json +++ b/packages/next-codemod/tsconfig.json @@ -3,7 +3,7 @@ "module": "commonjs", "sourceMap": true, "esModuleInterop": true, - "target": "es2015", + "target": "ES2022", "downlevelIteration": true, "preserveWatchOutput": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a2bcd5d5645a..cf4144e34b405 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1502,6 +1502,9 @@ importers: cheerio: specifier: 1.0.0-rc.9 version: 1.0.0-rc.9 + commander: + specifier: 12.1.0 + version: 12.1.0 compare-versions: specifier: 6.1.1 version: 6.1.1 @@ -1511,18 +1514,12 @@ importers: globby: specifier: 11.0.1 version: 11.0.1 - inquirer: - specifier: 7.3.3 - version: 7.3.3 is-git-clean: specifier: 1.1.0 version: 1.1.0 jscodeshift: specifier: 17.0.0 version: 17.0.0(@babel/preset-env@7.24.8(@babel/core@7.22.5)) - meow: - specifier: 7.0.1 - version: 7.0.1 picocolors: specifier: 1.0.0 version: 1.0.0 @@ -5117,9 +5114,6 @@ packages: '@types/minimatch@3.0.3': resolution: {integrity: sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==} - '@types/minimist@1.2.0': - resolution: {integrity: sha512-BsF2gEVEIOcbQCSwXR6V14fGD6QLLT0yQBK6RpblkxVYP9x8ANNThpxMUxV7h4KKjqMDR8qELlcnqrEoyvsohw==} - '@types/minimist@1.2.5': resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} @@ -10488,10 +10482,6 @@ packages: resolution: {integrity: sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==} engines: {node: '>=0.10.0'} - meow@7.0.1: - resolution: {integrity: sha512-tBKIQqVrAHqwit0vfuFPY3LlzJYkEOFyKa3bPgxzNl6q/RtN8KQ+ALYEASYuFayzSAsjlhXj/JZ10rH85Q6TUw==} - engines: {node: '>=10'} - meow@7.1.1: resolution: {integrity: sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==} engines: {node: '>=10'} @@ -19702,8 +19692,6 @@ snapshots: '@types/minimatch@3.0.3': {} - '@types/minimist@1.2.0': {} - '@types/minimist@1.2.5': {} '@types/mute-stream@0.0.4': @@ -26576,22 +26564,6 @@ snapshots: redent: 1.0.0 trim-newlines: 1.0.0 - meow@7.0.1: - dependencies: - '@types/minimist': 1.2.0 - arrify: 2.0.1 - camelcase: 6.2.0 - camelcase-keys: 6.2.2 - decamelize-keys: 1.1.0 - hard-rejection: 2.1.0 - minimist-options: 4.1.0 - normalize-package-data: 2.5.0 - read-pkg-up: 7.0.1 - redent: 3.0.0 - trim-newlines: 3.0.0 - type-fest: 0.13.1 - yargs-parser: 18.1.3 - meow@7.1.1: dependencies: '@types/minimist': 1.2.5