diff --git a/.changeset/real-oranges-rescue.md b/.changeset/real-oranges-rescue.md new file mode 100644 index 000000000..8e4c42e2d --- /dev/null +++ b/.changeset/real-oranges-rescue.md @@ -0,0 +1,18 @@ +--- +'skuba': minor +--- + +migrate: Introduce `skuba migrate node20` to automatically upgrade a project's Node.js version + +`skuba migrate node20` will attempt to automatically upgrade projects to Node.js 20. +It will look in the project root for Dockerfiles, `.nvmrc`, and Serverless files, +as well as CDK files in `infra/` and `.buildkite/` files, and try to upgrade them to a Node.js 20 version. + +skuba might not be able to upgrade all projects, so please check your project for any files that skuba missed. It's +possible that skuba will modify a file incorrectly, in which case please +[open an issue](https://github.com/seek-oss/skuba/issues/new). + +Node.js 20 comes with its own breaking changes, so please read the [Node.js 20 release notes](https://nodejs.org/en/blog/announcements/v20-release-announce) alongside the skuba release notes. In addition, + +- For AWS Lambda runtime updates to `nodejs20.x`, consider reading the [release announcement](https://aws.amazon.com/blogs/compute/node-js-20-x-runtime-now-available-in-aws-lambda/) as there are some breaking changes with this upgrade. +- You may need to upgrade your versions of CDK and Serverless as appropriate to support nodejs20.x. diff --git a/docs/cli/help.md b/docs/cli/help.md index a08a83daa..c01643934 100644 --- a/docs/cli/help.md +++ b/docs/cli/help.md @@ -1,6 +1,6 @@ --- parent: CLI -nav_order: 7 +nav_order: 8 --- # Help diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md new file mode 100644 index 000000000..a8d6cdeab --- /dev/null +++ b/docs/cli/migrate.md @@ -0,0 +1,37 @@ +--- +parent: CLI +nav_order: 7 +--- + +# Migrate + +--- + +## skuba migrate help + +Echoes the available **skuba** migrations + +```shell +skuba migrate help +``` + +--- + +## skuba migrate node20 + +`skuba migrate node20` will attempt to automatically upgrade projects to Node.js 20. +It will look in the project root for Dockerfiles, `.nvmrc`, and Serverless files, +as well as CDK files in `infra/` and `.buildkite/` files, and try to upgrade them to a Node.js 20 version. + +**skuba** might not be able to upgrade all projects, so please check your project for any files that **skuba** missed. It's +possible that **skuba** will modify a file incorrectly, in which case please +[open an issue](https://github.com/seek-oss/skuba/issues/new). + +Node.js 20 comes with its own breaking changes, so please read the [Node.js 20 release notes](https://nodejs.org/en/blog/announcements/v20-release-announce) alongside the skuba release notes. In addition, + +- For AWS Lambda runtime updates to `nodejs20.x`, consider reading the [release announcement](https://aws.amazon.com/blogs/compute/node-js-20-x-runtime-now-available-in-aws-lambda/) as there are some breaking changes with this upgrade. +- You may need to upgrade your versions of CDK and Serverless as appropriate to support nodejs20.x. + +```shell +skuba migrate node20 +``` diff --git a/docs/cli/run.md b/docs/cli/run.md index c88801b6d..ff57c0170 100644 --- a/docs/cli/run.md +++ b/docs/cli/run.md @@ -212,6 +212,6 @@ Execution should pause on the breakpoint until we hit `F5` or the `▶️` butto [`tsconfig-paths`]: https://github.com/dividab/tsconfig-paths [express]: https://expressjs.com/ [fastify]: https://www.fastify.io/ -[http server]: https://nodejs.org/docs/latest-v18.x/api/http.html#class-httpserver +[http server]: https://nodejs.org/docs/latest-v20.x/api/http.html#class-httpserver [koa]: https://koajs.com/ [node.js options]: https://nodejs.org/en/docs/guides/debugging-getting-started/#command-line-options diff --git a/package.json b/package.json index f59addfc7..3923b0468 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "eslint": "^8.11.0", "eslint-config-skuba": "3.1.0", "execa": "^5.0.0", + "fast-glob": "^3.3.2", "fdir": "^6.0.0", "fs-extra": "^11.0.0", "function-arguments": "^1.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e45357e27..c41b84c97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ dependencies: execa: specifier: ^5.0.0 version: 5.1.1 + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 fdir: specifier: ^6.0.0 version: 6.1.1(picomatch@3.0.1) diff --git a/scripts/package.ts b/scripts/package.ts index f71aa19d1..cb8469653 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -234,7 +234,7 @@ const main = async () => { 'CONTRIBUTING.md', path.join('dist-docs', 'CONTRIBUTING.md'), ), - // `fs.promises.cp` is still experimental in Node.js 18. + // `fs.promises.cp` is still experimental in Node.js 20. copy('site', 'dist-docs'), copy('docs', path.join('dist-docs', 'docs')), ]); diff --git a/src/cli/init/index.ts b/src/cli/init/index.ts index dfd438bb0..dfa90608f 100644 --- a/src/cli/init/index.ts +++ b/src/cli/init/index.ts @@ -13,7 +13,7 @@ import { ensureTemplateConfigDeletion, } from '../../utils/template'; import { runPrettier } from '../adapter/prettier'; -import { tryPatchRenovateConfig } from '../configure/patchRenovateConfig'; +import { tryPatchRenovateConfig } from '../lint/internalLints/patchRenovateConfig'; import { getConfig } from './getConfig'; import { initialiseRepo } from './git'; diff --git a/src/cli/lint/internal.ts b/src/cli/lint/internal.ts index fbbe2b26f..fec5fb7b6 100644 --- a/src/cli/lint/internal.ts +++ b/src/cli/lint/internal.ts @@ -3,10 +3,10 @@ import { inspect } from 'util'; import chalk from 'chalk'; import { type Logger, createLogger } from '../../utils/logging'; -import { upgradeSkuba } from '../configure/upgrade'; import { noSkubaTemplateJs } from './internalLints/noSkubaTemplateJs'; import { tryRefreshConfigFiles } from './internalLints/refreshConfigFiles'; +import { upgradeSkuba } from './internalLints/upgrade'; import type { Input } from './types'; export type InternalLintResult = { diff --git a/src/cli/configure/patchRenovateConfig.test.ts b/src/cli/lint/internalLints/patchRenovateConfig.test.ts similarity index 99% rename from src/cli/configure/patchRenovateConfig.test.ts rename to src/cli/lint/internalLints/patchRenovateConfig.test.ts index db15e106b..f0a0734a7 100644 --- a/src/cli/configure/patchRenovateConfig.test.ts +++ b/src/cli/lint/internalLints/patchRenovateConfig.test.ts @@ -2,7 +2,7 @@ import { inspect } from 'util'; import memfs, { vol } from 'memfs'; -import * as Git from '../../api/git'; +import * as Git from '../../../api/git'; import { tryPatchRenovateConfig } from './patchRenovateConfig'; diff --git a/src/cli/configure/patchRenovateConfig.ts b/src/cli/lint/internalLints/patchRenovateConfig.ts similarity index 92% rename from src/cli/configure/patchRenovateConfig.ts rename to src/cli/lint/internalLints/patchRenovateConfig.ts index 771710c15..cde1c59c1 100644 --- a/src/cli/configure/patchRenovateConfig.ts +++ b/src/cli/lint/internalLints/patchRenovateConfig.ts @@ -5,12 +5,12 @@ import fs from 'fs-extra'; import * as fleece from 'golden-fleece'; import { z } from 'zod'; -import * as Git from '../../api/git'; -import { log } from '../../utils/logging'; +import * as Git from '../../../api/git'; +import { log } from '../../../utils/logging'; +import { createDestinationFileReader } from '../../configure/analysis/project'; +import { RENOVATE_CONFIG_FILENAMES } from '../../configure/modules/renovate'; +import { formatPrettier } from '../../configure/processing/prettier'; -import { createDestinationFileReader } from './analysis/project'; -import { RENOVATE_CONFIG_FILENAMES } from './modules/renovate'; -import { formatPrettier } from './processing/prettier'; import type { PatchFunction, PatchReturnType } from './upgrade'; const RENOVATE_PRESETS = [ diff --git a/src/cli/configure/upgrade/index.test.ts b/src/cli/lint/internalLints/upgrade/index.test.ts similarity index 96% rename from src/cli/configure/upgrade/index.test.ts rename to src/cli/lint/internalLints/upgrade/index.test.ts index 25116a717..c0ce4d12e 100644 --- a/src/cli/configure/upgrade/index.test.ts +++ b/src/cli/lint/internalLints/upgrade/index.test.ts @@ -1,16 +1,16 @@ import { readdir, writeFile } from 'fs-extra'; import type { NormalizedPackageJson } from 'read-pkg-up'; -import { log } from '../../../utils/logging'; -import { getConsumerManifest } from '../../../utils/manifest'; -import { getSkubaVersion } from '../../../utils/version'; +import { log } from '../../../../utils/logging'; +import { getConsumerManifest } from '../../../../utils/manifest'; +import { getSkubaVersion } from '../../../../utils/version'; import { upgradeSkuba } from '.'; -jest.mock('../../../utils/manifest'); -jest.mock('../../../utils/version'); +jest.mock('../../../../utils/manifest'); +jest.mock('../../../../utils/version'); jest.mock('fs-extra'); -jest.mock('../../../utils/logging'); +jest.mock('../../../../utils/logging'); beforeEach(() => { jest.clearAllMocks(); diff --git a/src/cli/configure/upgrade/index.ts b/src/cli/lint/internalLints/upgrade/index.ts similarity index 90% rename from src/cli/configure/upgrade/index.ts rename to src/cli/lint/internalLints/upgrade/index.ts index e42e432e0..07eb7c995 100644 --- a/src/cli/configure/upgrade/index.ts +++ b/src/cli/lint/internalLints/upgrade/index.ts @@ -3,13 +3,13 @@ import path from 'path'; import { readdir, writeFile } from 'fs-extra'; import { gte, sort } from 'semver'; -import type { Logger } from '../../../utils/logging'; -import { getConsumerManifest } from '../../../utils/manifest'; -import { detectPackageManager } from '../../../utils/packageManager'; -import { getSkubaVersion } from '../../../utils/version'; -import type { SkubaPackageJson } from '../../init/writePackageJson'; -import type { InternalLintResult } from '../../lint/internal'; -import { formatPackage } from '../processing/package'; +import type { Logger } from '../../../../utils/logging'; +import { getConsumerManifest } from '../../../../utils/manifest'; +import { detectPackageManager } from '../../../../utils/packageManager'; +import { getSkubaVersion } from '../../../../utils/version'; +import { formatPackage } from '../../../configure/processing/package'; +import type { SkubaPackageJson } from '../../../init/writePackageJson'; +import type { InternalLintResult } from '../../internal'; export type Patches = Patch[]; export type Patch = { diff --git a/src/cli/configure/upgrade/patches/7.3.1/addEmptyExports.test.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/addEmptyExports.test.ts similarity index 96% rename from src/cli/configure/upgrade/patches/7.3.1/addEmptyExports.test.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/addEmptyExports.test.ts index 3c8c49a72..cd0cd75be 100644 --- a/src/cli/configure/upgrade/patches/7.3.1/addEmptyExports.test.ts +++ b/src/cli/lint/internalLints/upgrade/patches/7.3.1/addEmptyExports.test.ts @@ -1,7 +1,7 @@ import fs from 'fs-extra'; -import * as packageAnalysis from '../../../analysis/package'; -import * as projectAnalysis from '../../../analysis/project'; +import * as packageAnalysis from '../../../../../configure/analysis/package'; +import * as projectAnalysis from '../../../../../configure/analysis/project'; import { tryAddEmptyExports } from './addEmptyExports'; diff --git a/src/cli/configure/upgrade/patches/7.3.1/addEmptyExports.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/addEmptyExports.ts similarity index 85% rename from src/cli/configure/upgrade/patches/7.3.1/addEmptyExports.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/addEmptyExports.ts index 437577831..11c063229 100644 --- a/src/cli/configure/upgrade/patches/7.3.1/addEmptyExports.ts +++ b/src/cli/lint/internalLints/upgrade/patches/7.3.1/addEmptyExports.ts @@ -4,10 +4,10 @@ import { inspect } from 'util'; import fs from 'fs-extra'; import type { PatchFunction } from '../..'; -import { log } from '../../../../../utils/logging'; -import { getDestinationManifest } from '../../../analysis/package'; -import { createDestinationFileReader } from '../../../analysis/project'; -import { formatPrettier } from '../../../processing/prettier'; +import { log } from '../../../../../../utils/logging'; +import { getDestinationManifest } from '../../../../../configure/analysis/package'; +import { createDestinationFileReader } from '../../../../../configure/analysis/project'; +import { formatPrettier } from '../../../../../configure/processing/prettier'; const JEST_SETUP_FILES = ['jest.setup.ts', 'jest.setup.int.ts']; diff --git a/src/cli/configure/upgrade/patches/7.3.1/index.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/index.ts similarity index 100% rename from src/cli/configure/upgrade/patches/7.3.1/index.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/index.ts diff --git a/src/cli/configure/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.test.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.test.ts similarity index 97% rename from src/cli/configure/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.test.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.test.ts index d8ad850eb..d1f0974a4 100644 --- a/src/cli/configure/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.test.ts +++ b/src/cli/lint/internalLints/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.test.ts @@ -1,7 +1,7 @@ import fs from 'fs-extra'; -import * as packageAnalysis from '../../../analysis/package'; -import * as projectAnalysis from '../../../analysis/project'; +import * as packageAnalysis from '../../../../../configure/analysis/package'; +import * as projectAnalysis from '../../../../../configure/analysis/project'; import { tryMoveNpmrcOutOfIgnoreManagedSection } from './moveNpmrcOutOfIgnoreManagedSection'; diff --git a/src/cli/configure/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.ts similarity index 92% rename from src/cli/configure/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.ts index 41bc6da8e..3b4052980 100644 --- a/src/cli/configure/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.ts +++ b/src/cli/lint/internalLints/upgrade/patches/7.3.1/moveNpmrcOutOfIgnoreManagedSection.ts @@ -4,9 +4,9 @@ import { inspect } from 'util'; import fs from 'fs-extra'; import type { PatchFunction, PatchReturnType } from '../..'; -import { log } from '../../../../../utils/logging'; -import { NPMRC_LINES } from '../../../../../utils/npmrc'; -import { createDestinationFileReader } from '../../../analysis/project'; +import { log } from '../../../../../../utils/logging'; +import { NPMRC_LINES } from '../../../../../../utils/npmrc'; +import { createDestinationFileReader } from '../../../../../configure/analysis/project'; const NPMRC_IGNORE_SECTION = ` diff --git a/src/cli/configure/upgrade/patches/7.3.1/patchDockerfile.test.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/patchDockerfile.test.ts similarity index 100% rename from src/cli/configure/upgrade/patches/7.3.1/patchDockerfile.test.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/patchDockerfile.test.ts diff --git a/src/cli/configure/upgrade/patches/7.3.1/patchDockerfile.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/patchDockerfile.ts similarity index 90% rename from src/cli/configure/upgrade/patches/7.3.1/patchDockerfile.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/patchDockerfile.ts index 60a85e8b5..1255f2685 100644 --- a/src/cli/configure/upgrade/patches/7.3.1/patchDockerfile.ts +++ b/src/cli/lint/internalLints/upgrade/patches/7.3.1/patchDockerfile.ts @@ -3,8 +3,8 @@ import { inspect } from 'util'; import fs from 'fs-extra'; import type { PatchFunction, PatchReturnType } from '../..'; -import { log } from '../../../../../utils/logging'; -import { createDestinationFileReader } from '../../../analysis/project'; +import { log } from '../../../../../../utils/logging'; +import { createDestinationFileReader } from '../../../../../configure/analysis/project'; const DOCKERFILE_FILENAME = 'Dockerfile'; diff --git a/src/cli/configure/upgrade/patches/7.3.1/patchServerListener.test.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/patchServerListener.test.ts similarity index 95% rename from src/cli/configure/upgrade/patches/7.3.1/patchServerListener.test.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/patchServerListener.test.ts index 92497866b..7386bf8bc 100644 --- a/src/cli/configure/upgrade/patches/7.3.1/patchServerListener.test.ts +++ b/src/cli/lint/internalLints/upgrade/patches/7.3.1/patchServerListener.test.ts @@ -51,7 +51,7 @@ describe('patchServerListener', () => { }); // Gantry ALB default idle timeout is 30 seconds - // https://nodejs.org/docs/latest-v18.x/api/http.html#serverkeepalivetimeout + // https://nodejs.org/docs/latest-v20.x/api/http.html#serverkeepalivetimeout // Node default is 5 seconds // https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout // AWS recommends setting an application timeout larger than the load balancer @@ -73,7 +73,7 @@ describe('patchServerListener', () => { "src/listen.ts": "const listener = app.listen(config.port); // Gantry ALB default idle timeout is 30 seconds - // https://nodejs.org/docs/latest-v18.x/api/http.html#serverkeepalivetimeout + // https://nodejs.org/docs/latest-v20.x/api/http.html#serverkeepalivetimeout // Node default is 5 seconds // https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout // AWS recommends setting an application timeout larger than the load balancer @@ -117,7 +117,7 @@ describe('patchServerListener', () => { it('skips the templated Koa listener', async () => { const listener = await fs.promises.readFile( require.resolve( - '../../../../../../template/koa-rest-api/src/listen.ts', + '../../../../../../../template/koa-rest-api/src/listen.ts', ), 'utf-8', ); @@ -139,7 +139,7 @@ describe('patchServerListener', () => { it('skips the templated Express listener', async () => { const listener = await fs.promises.readFile( require.resolve( - '../../../../../../template/express-rest-api/src/listen.ts', + '../../../../../../../template/express-rest-api/src/listen.ts', ), 'utf-8', ); @@ -243,7 +243,7 @@ describe('patchServerListener', () => { it('skips the templated Koa listener', async () => { const listener = await fs.promises.readFile( require.resolve( - '../../../../../../template/koa-rest-api/src/listen.ts', + '../../../../../../../template/koa-rest-api/src/listen.ts', ), 'utf-8', ); @@ -265,7 +265,7 @@ describe('patchServerListener', () => { it('skips the templated Express listener', async () => { const listener = await fs.promises.readFile( require.resolve( - '../../../../../../template/express-rest-api/src/listen.ts', + '../../../../../../../template/express-rest-api/src/listen.ts', ), 'utf-8', ); diff --git a/src/cli/configure/upgrade/patches/7.3.1/patchServerListener.ts b/src/cli/lint/internalLints/upgrade/patches/7.3.1/patchServerListener.ts similarity index 86% rename from src/cli/configure/upgrade/patches/7.3.1/patchServerListener.ts rename to src/cli/lint/internalLints/upgrade/patches/7.3.1/patchServerListener.ts index 12727a78e..bc25ab0b5 100644 --- a/src/cli/configure/upgrade/patches/7.3.1/patchServerListener.ts +++ b/src/cli/lint/internalLints/upgrade/patches/7.3.1/patchServerListener.ts @@ -3,15 +3,15 @@ import { inspect } from 'util'; import fs from 'fs-extra'; import type { PatchFunction, PatchReturnType } from '../..'; -import { log } from '../../../../../utils/logging'; -import { createDestinationFileReader } from '../../../analysis/project'; -import { formatPrettier } from '../../../processing/prettier'; +import { log } from '../../../../../../utils/logging'; +import { createDestinationFileReader } from '../../../../../configure/analysis/project'; +import { formatPrettier } from '../../../../../configure/processing/prettier'; const SERVER_LISTENER_FILENAME = 'src/listen.ts'; const KEEP_ALIVE_CODE = ` // Gantry ALB default idle timeout is 30 seconds -// https://nodejs.org/docs/latest-v18.x/api/http.html#serverkeepalivetimeout +// https://nodejs.org/docs/latest-v20.x/api/http.html#serverkeepalivetimeout // Node default is 5 seconds // https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout // AWS recommends setting an application timeout larger than the load balancer diff --git a/src/cli/migrate/index.ts b/src/cli/migrate/index.ts new file mode 100644 index 000000000..650eedb95 --- /dev/null +++ b/src/cli/migrate/index.ts @@ -0,0 +1,39 @@ +import { log } from '../../utils/logging'; + +import { nodeVersionMigration } from './nodeVersion'; + +const migrations: Record Promise> = { + node20: () => nodeVersionMigration(20), +}; + +const logAvailableMigrations = () => { + log.ok('Available migrations:'); + Object.keys(migrations).forEach((migration) => { + log.ok(`- ${migration}`); + }); +}; + +export const migrate = async (args = process.argv.slice(2)) => { + if (!args[0]) { + log.err('Provide a migration to run.'); + logAvailableMigrations(); + process.exitCode = 1; + return; + } + + if (args.includes('--help') || args.includes('-h') || args[0] === 'help') { + logAvailableMigrations(); + return; + } + + const migration = migrations[args[0]]; + + if (!migration) { + log.err(`Migration "${args[0]}" is not a valid option.`); + logAvailableMigrations(); + process.exitCode = 1; + return; + } + + await migration(); +}; diff --git a/src/cli/migrate/nodeVersion/index.test.ts b/src/cli/migrate/nodeVersion/index.test.ts new file mode 100644 index 000000000..74665a999 --- /dev/null +++ b/src/cli/migrate/nodeVersion/index.test.ts @@ -0,0 +1,132 @@ +import memfs, { vol } from 'memfs'; + +import { nodeVersionMigration } from '.'; + +jest.mock('fs-extra', () => memfs); +jest.mock('fast-glob', () => ({ + glob: (pat: any, opts: any) => + jest.requireActual('fast-glob').glob(pat, { ...opts, fs: memfs }), +})); +jest.mock('../../../utils/logging'); + +const volToJson = () => vol.toJSON(process.cwd(), undefined, true); + +beforeEach(jest.clearAllMocks); +beforeEach(() => vol.reset()); + +describe('nodeVersionMigration', () => { + const scenarios: Array<{ + filesBefore: Record; + filesAfter?: Record; + scenario: string; + }> = [ + { + scenario: 'an empty project', + filesBefore: {}, + }, + { + scenario: 'several files to patch', + filesBefore: { + '.nvmrc': 'v18.1.2\n', + Dockerfile: 'FROM node:18.1.2\nRUN echo "hello"', + 'Dockerfile.dev-deps': + 'FROM --platform=linux/amd64 node:18-slim AS dev-deps\nRUN echo "hello"', + 'serverless.yml': + 'provider:\n logRetentionInDays: 30\n runtime: nodejs18.x\n region: ap-southeast-2', + 'serverless.melb.yaml': + 'provider:\n logRetentionInDays: 7\n runtime: nodejs16.x\n region: ap-southeast-4', + 'infra/myCoolStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_18_X,\n}`, + 'infra/myCoolFolder/evenCoolerStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_16_X,\n}`, + '.buildkite/pipeline.yml': + 'plugins:\n - docker#v3.0.0:\n image: node:18.1.2-slim\n', + '.buildkite/pipeline2.yml': + 'plugins:\n - docker#v3.0.0:\n image: node:18\n', + }, + filesAfter: { + '.nvmrc': '20\n', + Dockerfile: 'FROM node:20\nRUN echo "hello"', + 'Dockerfile.dev-deps': + 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'serverless.yml': + 'provider:\n logRetentionInDays: 30\n runtime: nodejs20.x\n region: ap-southeast-2', + 'serverless.melb.yaml': + 'provider:\n logRetentionInDays: 7\n runtime: nodejs20.x\n region: ap-southeast-4', + 'infra/myCoolStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_20_X,\n}`, + 'infra/myCoolFolder/evenCoolerStack.ts': `const worker = new aws_lambda.Function(this, 'worker', {\n architecture: aws_lambda.Architecture[architecture],\n code: new aws_lambda.AssetCode('./lib'),\n runtime: aws_lambda.Runtime.NODEJS_20_X,\n}`, + '.buildkite/pipeline.yml': + 'plugins:\n - docker#v3.0.0:\n image: node:20-slim\n', + '.buildkite/pipeline2.yml': + 'plugins:\n - docker#v3.0.0:\n image: node:20\n', + }, + }, + { + scenario: 'various node formats', + filesBefore: { + '.nvmrc': '18.3.4\n', + 'Dockerfile.1': 'FROM node:18.1.2\nRUN echo "hello"', + 'Dockerfile.2': 'FROM node:18\nRUN echo "hello"', + 'Dockerfile.3': 'FROM node:18-slim\nRUN echo "hello"', + 'Dockerfile.4': 'FROM node:18.1.2-slim\nRUN echo "hello"', + 'Dockerfile.5': + 'FROM --platform=linux/amd64 node:18.1.2 AS dev-deps\nRUN echo "hello"', + 'Dockerfile.6': + 'FROM --platform=linux/amd64 node:18 AS dev-deps\nRUN echo "hello"', + 'Dockerfile.7': + 'FROM --platform=linux/amd64 node:18-slim AS dev-deps\nRUN echo "hello"', + 'Dockerfile.8': + 'FROM --platform=linux/amd64 node:18.1.2-slim AS dev-deps\nRUN echo "hello"', + 'Dockerfile.9': + 'FROM gcr.io/distroless/nodejs18-debian12\nRUN echo "hello"', + 'Dockerfile.10': + 'FROM --platform=linux/amd64 gcr.io/distroless/nodejs18-debian12 AS dev-deps\nRUN echo "hello"', + }, + filesAfter: { + '.nvmrc': '20\n', + 'Dockerfile.1': 'FROM node:20\nRUN echo "hello"', + 'Dockerfile.2': 'FROM node:20\nRUN echo "hello"', + 'Dockerfile.3': 'FROM node:20-slim\nRUN echo "hello"', + 'Dockerfile.4': 'FROM node:20-slim\nRUN echo "hello"', + 'Dockerfile.5': + 'FROM --platform=linux/amd64 node:20 AS dev-deps\nRUN echo "hello"', + 'Dockerfile.6': + 'FROM --platform=linux/amd64 node:20 AS dev-deps\nRUN echo "hello"', + 'Dockerfile.7': + 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'Dockerfile.8': + 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'Dockerfile.9': + 'FROM gcr.io/distroless/nodejs20-debian12\nRUN echo "hello"', + 'Dockerfile.10': + 'FROM --platform=linux/amd64 gcr.io/distroless/nodejs20-debian12 AS dev-deps\nRUN echo "hello"', + }, + }, + { + scenario: 'already node 20', + filesBefore: { + '.nvmrc': '20\n', + Dockerfile: 'FROM node:20\nRUN echo "hello"', + 'Dockerfile.dev-deps': + 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'serverless.yml': + 'provider:\n logRetentionInDays: 30\n runtime: nodejs20.x\n region: ap-southeast-2', + }, + }, + { + scenario: 'not detectable', + filesBefore: { + Dockerfile: 'FROM node:latest\nRUN echo "hello"', + }, + }, + ]; + + it.each(scenarios)( + 'handles $scenario', + async ({ filesBefore, filesAfter }) => { + vol.fromJSON(filesBefore, process.cwd()); + + await nodeVersionMigration(20); + + expect(volToJson()).toEqual(filesAfter ?? filesBefore); + }, + ); +}); diff --git a/src/cli/migrate/nodeVersion/index.ts b/src/cli/migrate/nodeVersion/index.ts new file mode 100644 index 000000000..6d14357aa --- /dev/null +++ b/src/cli/migrate/nodeVersion/index.ts @@ -0,0 +1,95 @@ +import { inspect } from 'util'; + +import { glob } from 'fast-glob'; +import fs from 'fs-extra'; + +import { log } from '../../../utils/logging'; +import { createDestinationFileReader } from '../../configure/analysis/project'; + +type SubPatch = ( + | { files: string; file?: never } + | { file: string; files?: never } +) & { + test?: RegExp; + replace: string; +}; + +const subPatches: SubPatch[] = [ + { file: '.nvmrc', replace: '<%- version %>\n' }, + { + files: 'Dockerfile*', + test: /^FROM(.*) node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?( .+|)$/gm, + replace: 'FROM$1 node:<%- version %>$3$4', + }, + { + files: 'Dockerfile*', + test: /^FROM(.*) gcr.io\/distroless\/nodejs\d+-debian(.+)$/gm, + replace: 'FROM$1 gcr.io/distroless/nodejs<%- version %>-debian$2', + }, + { + files: 'serverless*.y*ml', + test: /nodejs\d+.x/gm, + replace: 'nodejs<%- version %>.x', + }, + { + files: 'infra/**/*.ts', + test: /NODEJS_\d+_X/g, + replace: 'NODEJS_<%- version %>_X', + }, + { + files: '.buildkite/*', + test: /image: node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?$/gm, + replace: 'image: node:<%- version %>$2', + }, +]; + +const runSubPatch = async (version: number, dir: string, patch: SubPatch) => { + const readFile = createDestinationFileReader(dir); + const paths = patch.file + ? [patch.file] + : await glob(patch.files ?? [], { cwd: dir }); + + await Promise.all( + paths.map(async (path) => { + const contents = await readFile(path); + if (!contents) { + return; + } + + if (patch.test && !patch.test.test(contents)) { + return; + } + + const templated = patch.replace.replaceAll( + '<%- version %>', + version.toString(), + ); + + await fs.promises.writeFile( + path, + patch.test ? contents.replaceAll(patch.test, templated) : templated, + ); + }), + ); +}; + +const upgrade = async (version: number, dir: string) => { + await Promise.all( + subPatches.map((subPatch) => runSubPatch(version, dir, subPatch)), + ); +}; + +export const nodeVersionMigration = async ( + version: number, + dir = process.cwd(), +) => { + log.ok(`Upgrading to Node.js ${version}`); + try { + await upgrade(version, dir); + log.ok('Upgraded to Node.js', version); + } catch (err) { + log.err('Failed to upgrade'); + log.subtle(inspect(err)); + process.exitCode = 1; + } +}; diff --git a/src/utils/command.ts b/src/utils/command.ts index f8be13359..b46bb3d8a 100644 --- a/src/utils/command.ts +++ b/src/utils/command.ts @@ -19,6 +19,7 @@ export const COMMAND_LIST = [ 'help', 'init', 'lint', + 'migrate', 'node', 'release', 'start',