diff --git a/.eslintignore b/.eslintignore index 0523c232e1afd..009437967bb4e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -23,6 +23,7 @@ packages/react-dev-overlay/lib/** .github/actions/next-stats-action/.work packages/next-codemod/transforms/__testfixtures__/**/* packages/next-codemod/transforms/__tests__/**/* +packages/next-codemod/bin/__testfixtures__/**/* packages/next-codemod/**/*.js packages/next-codemod/**/*.d.ts packages/next-env/**/*.d.ts diff --git a/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/README.md b/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/README.md new file mode 100644 index 0000000000000..d92654d0fc2eb --- /dev/null +++ b/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/README.md @@ -0,0 +1 @@ +Installs `@vercel/functions` diff --git a/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/app/route.tsx b/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/app/route.tsx new file mode 100644 index 0000000000000..332fe04e8a0a3 --- /dev/null +++ b/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/app/route.tsx @@ -0,0 +1,8 @@ +// @ts-nocheck +import { type NextRequest, NextResponse } from 'next/server' + +export function GET(request: NextRequest) { + const geo = request.geo + const ip = request.ip + return NextResponse.json({ geo, ip }) +} diff --git a/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/package.json b/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/package.json new file mode 100644 index 0000000000000..166f6fc2dceb9 --- /dev/null +++ b/packages/next-codemod/bin/__testfixtures__/geo-ip-usage/package.json @@ -0,0 +1,11 @@ +{ + "name": "geo-ip-usage", + "scripts": { + "dev": "next dev --turbo" + }, + "dependencies": { + "next": "15.0.0-canary.152", + "react": "19.0.0-rc-94e652d5-20240912", + "react-dom": "19.0.0-rc-94e652d5-20240912" + } +} diff --git a/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/README.md b/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/README.md new file mode 100644 index 0000000000000..2e68defaa0eab --- /dev/null +++ b/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/README.md @@ -0,0 +1 @@ +Should not install `@vercel/functions` diff --git a/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/app/page.tsx b/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/app/page.tsx new file mode 100644 index 0000000000000..c17431379f962 --- /dev/null +++ b/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null +} diff --git a/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/package.json b/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/package.json new file mode 100644 index 0000000000000..cd02e31837806 --- /dev/null +++ b/packages/next-codemod/bin/__testfixtures__/no-geo-ip-usage/package.json @@ -0,0 +1,11 @@ +{ + "name": "no-geo-ip-usage", + "scripts": { + "dev": "next dev --turbo" + }, + "dependencies": { + "next": "15.0.0-canary.152", + "react": "19.0.0-rc-94e652d5-20240912", + "react-dom": "19.0.0-rc-94e652d5-20240912" + } +} diff --git a/packages/next-codemod/bin/transform.ts b/packages/next-codemod/bin/transform.ts index 56c66cf889b55..8fdaa90e12ff2 100644 --- a/packages/next-codemod/bin/transform.ts +++ b/packages/next-codemod/bin/transform.ts @@ -1,6 +1,7 @@ import execa from 'execa' import globby from 'globby' import prompts from 'prompts' +import stripAnsi from 'strip-ansi' import { join } from 'node:path' import { installPackages, uninstallPackage } from '../lib/handle-package' import { @@ -79,6 +80,25 @@ export async function runTransform( transformer = res.transformer } + if (transformer === 'next-request-geo-ip') { + const { isAppDeployedToVercel } = await prompts( + { + type: 'confirm', + name: 'isAppDeployedToVercel', + message: + 'Is your app deployed to Vercel? (Required to apply the selected codemod)', + initial: true, + }, + { onCancel } + ) + if (!isAppDeployedToVercel) { + console.log( + 'Skipping codemod "next-request-geo-ip" as your app is not deployed to Vercel.' + ) + return + } + } + const filesExpanded = expandFilePathsIfNeeded([directory]) if (!filesExpanded.length) { @@ -126,22 +146,77 @@ export async function runTransform( console.log(`Executing command: jscodeshift ${args.join(' ')}`) - const result = execa.sync(jscodeshiftExecutable, args, { - stdio: 'inherit', - stripFinalNewline: false, + const execaChildProcess = execa(jscodeshiftExecutable, args, { + // include ANSI color codes + // Note: execa merges env with existing env by default. + env: process.stdout.isTTY ? { FORCE_COLOR: 'true' } : {}, }) - if (result.failed) { - throw new Error(`jscodeshift exited with code ${result.exitCode}`) - } + // "\n" + "a\n" + "b\n" + let lastThreeLineBreaks = '' - if (!dry && transformer === 'built-in-next-font') { - const { uninstallNextFont } = await prompts({ - type: 'confirm', - name: 'uninstallNextFont', - message: 'Do you want to uninstall `@next/font`?', - initial: true, + if (execaChildProcess.stdout) { + execaChildProcess.stdout.pipe(process.stdout) + execaChildProcess.stderr.pipe(process.stderr) + + // The last two lines contain the successful transformation count as "N ok". + // To save memory, we "slide the window" to keep only the last three line breaks. + // We save three line breaks because the EOL is always "\n". + execaChildProcess.stdout.on('data', (chunk) => { + lastThreeLineBreaks += chunk.toString('utf-8') + + let cutoff = lastThreeLineBreaks.length + + // Note: the stdout ends with "\n". + // "foo\n" + "bar\n" + "baz\n" -> "\nbar\nbaz\n" + // "\n" + "foo\n" + "bar\n" -> "\nfoo\nbar\n" + + for (let i = 0; i < 3; i++) { + cutoff = lastThreeLineBreaks.lastIndexOf('\n', cutoff) - 1 + } + + if (cutoff > 0 && cutoff < lastThreeLineBreaks.length) { + lastThreeLineBreaks = lastThreeLineBreaks.slice(cutoff + 1) + } }) + } + + try { + const result = await execaChildProcess + + if (result.failed) { + throw new Error(`jscodeshift exited with code ${result.exitCode}`) + } + } catch (error) { + throw error + } + + // With ANSI color codes, it will be "\x1B[39m\x1B[32m0 ok". + // Without, it will be "0 ok". + const targetOkLine = lastThreeLineBreaks.split('\n').at(-3) + + if (!targetOkLine.endsWith('ok')) { + throw new Error( + `Failed to parse the successful transformation count "${targetOkLine}". This is a bug in the codemod tool.` + ) + } + + const stripped = stripAnsi(targetOkLine) + // "N ok" -> "N" + const parsedNum = parseInt(stripped.split(' ')[0], 10) + const hasChanges = parsedNum > 0 + + if (!dry && transformer === 'built-in-next-font' && hasChanges) { + const { uninstallNextFont } = await prompts( + { + type: 'confirm', + name: 'uninstallNextFont', + message: + '`built-in-next-font` should have removed all usages of `@next/font`. Do you want to uninstall `@next/font`?', + initial: true, + }, + { onCancel } + ) if (uninstallNextFont) { console.log('Uninstalling `@next/font`') @@ -149,17 +224,11 @@ export async function runTransform( } } - if (!dry && transformer === 'next-request-geo-ip') { - const { installVercelFunctions } = await prompts({ - type: 'confirm', - name: 'installVercelFunctions', - message: 'Do you want to install `@vercel/functions`?', - initial: true, - }) - - if (installVercelFunctions) { - console.log('Installing `@vercel/functions`...') - installPackages(['@vercel/functions']) - } + // When has changes, it requires `@vercel/functions`, so skip prompt. + if (!dry && transformer === 'next-request-geo-ip' && hasChanges) { + console.log( + 'Installing `@vercel/functions` because the `next-request-geo-ip` made changes.' + ) + installPackages(['@vercel/functions']) } } diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index 7b0a86904b999..a657d62da6b96 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -17,7 +17,8 @@ "jscodeshift": "17.0.0", "picocolors": "1.0.0", "prompts": "2.4.2", - "semver": "7.6.3" + "semver": "7.6.3", + "strip-ansi": "6.0.0" }, "files": [ "transforms/*.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fa4c0e2ab9ca..c192137729f7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1538,6 +1538,9 @@ importers: semver: specifier: 7.6.3 version: 7.6.3 + strip-ansi: + specifier: 6.0.0 + version: 6.0.0 devDependencies: '@types/find-up': specifier: 4.0.0 @@ -12676,7 +12679,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.11.0: @@ -18029,7 +18031,7 @@ snapshots: micromatch: 4.0.5 pretty-format: 29.7.0 slash: 3.0.0 - strip-ansi: 6.0.1 + strip-ansi: 6.0.0 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -21659,7 +21661,7 @@ snapshots: cliui@7.0.4: dependencies: string-width: 4.2.3 - strip-ansi: 6.0.1 + strip-ansi: 6.0.0 wrap-ansi: 7.0.0 cliui@8.0.1: @@ -27993,7 +27995,7 @@ snapshots: is-interactive: 1.0.0 log-symbols: 3.0.0 mute-stream: 0.0.8 - strip-ansi: 6.0.1 + strip-ansi: 6.0.0 wcwidth: 1.0.1 ora@5.4.1: @@ -32128,7 +32130,7 @@ snapshots: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 - strip-ansi: 6.0.1 + strip-ansi: 6.0.0 wrap-ansi@8.1.0: dependencies: