diff --git a/src/cli/lint/internalLints/upgrade/patches/10.0.0/index.ts b/src/cli/lint/internalLints/upgrade/patches/10.0.0/index.ts new file mode 100644 index 000000000..96745e832 --- /dev/null +++ b/src/cli/lint/internalLints/upgrade/patches/10.0.0/index.ts @@ -0,0 +1,10 @@ +import type { Patches } from '../..'; + +import { tryUpgradeNode } from './upgradeNode'; + +export const patches: Patches = [ + { + apply: tryUpgradeNode, + description: 'TODO', + }, +]; diff --git a/src/cli/lint/internalLints/upgrade/patches/10.0.0/upgradeNode.ts b/src/cli/lint/internalLints/upgrade/patches/10.0.0/upgradeNode.ts new file mode 100644 index 000000000..c42ae6da5 --- /dev/null +++ b/src/cli/lint/internalLints/upgrade/patches/10.0.0/upgradeNode.ts @@ -0,0 +1,31 @@ +import { inspect } from 'util'; + +import { Env } from 'skuba-dive'; + +import type { PatchFunction, PatchReturnType } from '../..'; +import { log } from '../../../../../../utils/logging'; +import { nodeVersionMigration } from '../../../../../migrate/nodeVersion'; + +const upgradeNode: PatchFunction = async ({ + mode, +}): Promise => { + if (mode === 'lint' || Env.string('SKIP_NODE_UPGRADE') === 'true') { + return { + result: 'apply', + }; + } + + await nodeVersionMigration({ nodeVersion: 22, ECMAScriptVersion: 'ES2024' }); + + return { result: 'apply' }; +}; + +export const tryUpgradeNode: PatchFunction = async (config) => { + try { + return await upgradeNode(config); + } catch (err) { + log.warn('Failed to patch Docker images'); + log.subtle(inspect(err)); + return { result: 'skip', reason: 'due to an error' }; + } +}; diff --git a/src/cli/migrate/index.ts b/src/cli/migrate/index.ts index 650eedb95..92d6f0751 100644 --- a/src/cli/migrate/index.ts +++ b/src/cli/migrate/index.ts @@ -3,7 +3,10 @@ import { log } from '../../utils/logging'; import { nodeVersionMigration } from './nodeVersion'; const migrations: Record Promise> = { - node20: () => nodeVersionMigration(20), + node20: () => + nodeVersionMigration({ nodeVersion: 20, ECMAScriptVersion: 'ES2023' }), + node22: () => + nodeVersionMigration({ nodeVersion: 22, ECMAScriptVersion: 'ES2024' }), }; const logAvailableMigrations = () => { diff --git a/src/cli/migrate/nodeVersion/getNode22TypesVersion.ts b/src/cli/migrate/nodeVersion/getNode22TypesVersion.ts new file mode 100644 index 000000000..672f67a8d --- /dev/null +++ b/src/cli/migrate/nodeVersion/getNode22TypesVersion.ts @@ -0,0 +1,6 @@ +import { execSync } from 'child_process'; + +export const getNode22TypesVersion = (major: number) => + execSync( + `npm show @types/node@^${major} version --json | jq '.[-1]'`, + ).toString(); diff --git a/src/cli/migrate/nodeVersion/index.test.ts b/src/cli/migrate/nodeVersion/index.test.ts index 74665a999..5f1d2d859 100644 --- a/src/cli/migrate/nodeVersion/index.test.ts +++ b/src/cli/migrate/nodeVersion/index.test.ts @@ -1,6 +1,19 @@ import memfs, { vol } from 'memfs'; -import { nodeVersionMigration } from '.'; +import * as getNode22TypesVersionModule from './getNode22TypesVersion'; +import * as packageJsonChecks from './packageJsonChecks'; + +import { getNode22TypeVersion, nodeVersionMigration } from '.'; + +jest + .spyOn(getNode22TypesVersionModule, 'getNode22TypesVersion') + .mockReturnValue('22.9.0'); + +jest + .spyOn(packageJsonChecks, 'checkServerlessVersion') + .mockResolvedValue(undefined); + +jest.spyOn(packageJsonChecks, 'checkSkubaType').mockResolvedValue(undefined); jest.mock('fs-extra', () => memfs); jest.mock('fast-glob', () => ({ @@ -11,9 +24,10 @@ jest.mock('../../../utils/logging'); const volToJson = () => vol.toJSON(process.cwd(), undefined, true); -beforeEach(jest.clearAllMocks); beforeEach(() => vol.reset()); +afterEach(() => jest.clearAllMocks()); + describe('nodeVersionMigration', () => { const scenarios: Array<{ filesBefore: Record; @@ -41,22 +55,30 @@ describe('nodeVersionMigration', () => { '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', + '.buildkite/pipline3.yml': + 'plugins:\n - docker#v3.0.0:\n image: public.ecr.aws/docker/library/node:20-alpine\n', + '.node-version': '18.1.2\n', + '.node-version2': 'v20.15.0\n', }, filesAfter: { - '.nvmrc': '20\n', - Dockerfile: 'FROM node:20\nRUN echo "hello"', + '.nvmrc': '22\n', + Dockerfile: 'FROM node:22\nRUN echo "hello"', 'Dockerfile.dev-deps': - 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"', 'serverless.yml': - 'provider:\n logRetentionInDays: 30\n runtime: nodejs20.x\n region: ap-southeast-2', + 'provider:\n logRetentionInDays: 30\n runtime: nodejs22.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}`, + 'provider:\n logRetentionInDays: 7\n runtime: nodejs22.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_22_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_22_X,\n}`, '.buildkite/pipeline.yml': - 'plugins:\n - docker#v3.0.0:\n image: node:20-slim\n', + 'plugins:\n - docker#v3.0.0:\n image: node:22-slim\n', '.buildkite/pipeline2.yml': - 'plugins:\n - docker#v3.0.0:\n image: node:20\n', + 'plugins:\n - docker#v3.0.0:\n image: node:22\n', + '.buildkite/pipline3.yml': + 'plugins:\n - docker#v3.0.0:\n image: public.ecr.aws/docker/library/node:22-alpine\n', + '.node-version': '22\n', + '.node-version2': 'v22\n', }, }, { @@ -79,36 +101,48 @@ describe('nodeVersionMigration', () => { '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"', + 'Dockerfile.11': + 'FROM --platform=${BUILDPLATFORM:-arm64} gcr.io/distroless/nodejs20-debian12@sha256:9f43117c3e33c3ed49d689e51287a246edef3af0afed51a54dc0a9095b2b3ef9 AS runtime', + 'Dockerfile.12': + '# syntax=docker/dockerfile:1.10@sha256:865e5dd094beca432e8c0a1d5e1c465db5f998dca4e439981029b3b81fb39ed5\nFROM --platform=arm64 node:20@sha256:a5e0ed56f2c20b9689e0f7dd498cac7e08d2a3a283e92d9304e7b9b83e3c6ff3 AS dev-deps', + 'Dockerfile.13': + 'FROM public.ecr.aws/docker/library/node:20-alpine@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9 AS runtime', }, 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"', + '.nvmrc': '22\n', + 'Dockerfile.1': 'FROM node:22\nRUN echo "hello"', + 'Dockerfile.2': 'FROM node:22\nRUN echo "hello"', + 'Dockerfile.3': 'FROM node:22-slim\nRUN echo "hello"', + 'Dockerfile.4': 'FROM node:22-slim\nRUN echo "hello"', 'Dockerfile.5': - 'FROM --platform=linux/amd64 node:20 AS dev-deps\nRUN echo "hello"', + 'FROM --platform=linux/amd64 node:22 AS dev-deps\nRUN echo "hello"', 'Dockerfile.6': - 'FROM --platform=linux/amd64 node:20 AS dev-deps\nRUN echo "hello"', + 'FROM --platform=linux/amd64 node:22 AS dev-deps\nRUN echo "hello"', 'Dockerfile.7': - 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"', 'Dockerfile.8': - 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"', 'Dockerfile.9': - 'FROM gcr.io/distroless/nodejs20-debian12\nRUN echo "hello"', + 'FROM gcr.io/distroless/nodejs22-debian12\nRUN echo "hello"', 'Dockerfile.10': - 'FROM --platform=linux/amd64 gcr.io/distroless/nodejs20-debian12 AS dev-deps\nRUN echo "hello"', + 'FROM --platform=linux/amd64 gcr.io/distroless/nodejs22-debian12 AS dev-deps\nRUN echo "hello"', + 'Dockerfile.11': + 'FROM --platform=${BUILDPLATFORM:-arm64} gcr.io/distroless/nodejs22-debian12 AS runtime', + 'Dockerfile.12': + '# syntax=docker/dockerfile:1.10@sha256:865e5dd094beca432e8c0a1d5e1c465db5f998dca4e439981029b3b81fb39ed5\nFROM --platform=arm64 node:22 AS dev-deps', + 'Dockerfile.13': + 'FROM public.ecr.aws/docker/library/node:22-alpine AS runtime', }, }, { - scenario: 'already node 20', + scenario: 'already node 22', filesBefore: { - '.nvmrc': '20\n', - Dockerfile: 'FROM node:20\nRUN echo "hello"', + '.nvmrc': '22\n', + Dockerfile: 'FROM node:22\nRUN echo "hello"', 'Dockerfile.dev-deps': - 'FROM --platform=linux/amd64 node:20-slim AS dev-deps\nRUN echo "hello"', + 'FROM --platform=linux/amd64 node:22-slim AS dev-deps\nRUN echo "hello"', 'serverless.yml': - 'provider:\n logRetentionInDays: 30\n runtime: nodejs20.x\n region: ap-southeast-2', + 'provider:\n logRetentionInDays: 30\n runtime: nodejs22.x\n region: ap-southeast-2', }, }, { @@ -117,6 +151,49 @@ describe('nodeVersionMigration', () => { Dockerfile: 'FROM node:latest\nRUN echo "hello"', }, }, + { + scenario: 'node types', + filesBefore: { + 'package.json': '"@types/node": "^14.0.0"', + '1/package.json': '"@types/node": "18.0.0"', + '2/package.json': `"engines": {\n"node": ">=18"\n},\n`, + '3/package.json': `"engines": {\n"node": ">=18"\n},\n"skuba": {\n"type": "package"\n}`, + '4/package.json': `"engines": {\n"node": ">=18"\n},\n"skuba": {\n"type": "application"\n}`, + }, + filesAfter: { + 'package.json': '"@types/node": "^22.9.0"', + '1/package.json': '"@types/node": "22.9.0"', + '2/package.json': `"engines": {\n"node": ">=22"\n},\n`, + '3/package.json': `"engines": {\n"node": ">=18"\n},\n"skuba": {\n"type": "package"\n}`, + '4/package.json': `"engines": {\n"node": ">=22"\n},\n"skuba": {\n"type": "application"\n}`, + }, + }, + { + scenario: 'tsconfig target', + filesBefore: { + 'tsconfig.json': '"target": "ES2020"', + '1/tsconfig.json': '"target": "es2014"', + '2/tsconfig.json': '"target": "ESNext"', + }, + filesAfter: { + 'tsconfig.json': '"target": "ES2024"', + '1/tsconfig.json': '"target": "ES2024"', + '2/tsconfig.json': '"target": "ES2024"', + }, + }, + { + scenario: 'docker-compose.yml target', + filesBefore: { + 'docker-compose.yml': 'image: node:18.1.2\n', + 'docker-compose.dev.yml': 'image: node:18\n', + 'docker-compose.prod.yml': 'image: node:18-slim\n', + }, + filesAfter: { + 'docker-compose.yml': 'image: node:22\n', + 'docker-compose.dev.yml': 'image: node:22\n', + 'docker-compose.prod.yml': 'image: node:22-slim\n', + }, + }, ]; it.each(scenarios)( @@ -124,9 +201,40 @@ describe('nodeVersionMigration', () => { async ({ filesBefore, filesAfter }) => { vol.fromJSON(filesBefore, process.cwd()); - await nodeVersionMigration(20); + await nodeVersionMigration({ + nodeVersion: 22, + ECMAScriptVersion: 'ES2024', + }); expect(volToJson()).toEqual(filesAfter ?? filesBefore); }, ); }); + +describe('getNodeTypesVersion', () => { + it('finds the latest node22 types version', () => { + const { version, err } = getNode22TypeVersion(22, '22.9.0'); + expect(version).toBe('22.9.0'); + expect(err).toBeUndefined(); + }); + + it('defaults to 22.9.0 if the exec returns an invalid version', () => { + jest + .spyOn(getNode22TypesVersionModule, 'getNode22TypesVersion') + .mockReturnValue('This is not a version'); + const { version, err } = getNode22TypeVersion(22, '22.9.0'); + expect(version).toBe('22.9.0'); + expect(err).toBe('Failed to fetch latest version, using fallback version'); + }); + + it('defaults to 22.9.0 if the exec fails', () => { + jest + .spyOn(getNode22TypesVersionModule, 'getNode22TypesVersion') + .mockReturnValue( + new Error('Failed to fetch latest version') as unknown as string, + ); + const { version, err } = getNode22TypeVersion(22, '22.9.0'); + expect(version).toBe('22.9.0'); + expect(err).toBe('Failed to fetch latest version, using fallback version'); + }); +}); diff --git a/src/cli/migrate/nodeVersion/index.ts b/src/cli/migrate/nodeVersion/index.ts index 6d14357aa..a9d13a84c 100644 --- a/src/cli/migrate/nodeVersion/index.ts +++ b/src/cli/migrate/nodeVersion/index.ts @@ -6,44 +6,149 @@ 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; +import { getNode22TypesVersion } from './getNode22TypesVersion'; +import { checkServerlessVersion, checkSkubaType } from './packageJsonChecks'; + +const DEFAULT_NODE_TYPES = '22.9.0'; + +type SubPatch = + | (({ files: string; file?: never } | { file: string; files?: never }) & { + test?: RegExp; + replace: string; + id: string; + }) + | Array< + ({ files: string; file?: never } | { file: string; files?: never }) & { + test?: RegExp; + replace: string; + id: string; + } + >; + +type VersionResult = { + version: string; + err: string | undefined; +}; + +export const getNode22TypeVersion = ( + major: number, + defaultVersion: string, +): VersionResult => { + try { + const version = getNode22TypesVersion(major); + if (!version || !/^22.\d+\.\d+$/.test(version)) { + throw new Error('No version found'); + } + return { + version, + err: undefined, + }; + } catch { + return { + version: defaultVersion, + err: 'Failed to fetch latest version, using fallback version', + }; + } }; +const SHA_REGEX = /(?<=node.*)(@sha256:[a-f0-9]{64})/gm; + const subPatches: SubPatch[] = [ - { file: '.nvmrc', replace: '<%- version %>\n' }, + { id: 'nvmrc', file: '.nvmrc', replace: '<%- version %>\n' }, { + id: 'Dockerfile-1', files: 'Dockerfile*', - test: /^FROM(.*) node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?( .+|)$/gm, - replace: 'FROM$1 node:<%- version %>$3$4', + test: /^FROM(.*) (public.ecr.aws\/docker\/library\/)?node:[0-9.]+(@sha256:[a-f0-9]{64})?(\.[^- \n]+)?(-[^ \n]+)?( .+|)$/gm, + replace: 'FROM$1 $2node:<%- version %>$3$5$6', }, { + id: 'Dockerfile-2', files: 'Dockerfile*', test: /^FROM(.*) gcr.io\/distroless\/nodejs\d+-debian(.+)$/gm, replace: 'FROM$1 gcr.io/distroless/nodejs<%- version %>-debian$2', }, { + id: 'serverless', files: 'serverless*.y*ml', test: /nodejs\d+.x/gm, replace: 'nodejs<%- version %>.x', }, + [ + { + id: 'cdk-1', + files: 'infra/**/*.ts', + test: /NODEJS_\d+_X/g, + replace: 'NODEJS_<%- version %>_X', + }, + { + id: 'cdk-2', + files: 'infra/**/*.ts', + test: /(target:\s*'node)(\d+)(.+)$/gm, + replace: '$1<%- version %>$3', + }, + ], + { + id: 'buildkite', + files: '**/.buildkite/*', + test: /(image: )(public.ecr.aws\/docker\/library\/)?(node:)[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?$/gm, + replace: '$1$2$3<%- version %>$5', + }, + { + id: 'node-version', + files: '.node-version*', + test: /(v)?\d+\.\d+\.\d+(.+)?/gm, + replace: '$1<%- version %>$2', + }, + { + id: 'package-json-1', + files: '**/package.json', + test: /("@types\/node": ")(\^)?[0-9.]+"/gm, + replace: '$1$2<%- version %>"', + }, + { + id: 'package-json-2', + files: '**/package.json', + test: /("engines":\s*{[^}]*"node":\s*">=)(\d+)("[^}]*})/gm, + replace: '$1<%- version %>$3', + }, { - files: 'infra/**/*.ts', - test: /NODEJS_\d+_X/g, - replace: 'NODEJS_<%- version %>_X', + id: 'tsconfig', + files: '**/tsconfig.json', + test: /("target":\s*")(ES?:[0-9]+|Next|[A-Za-z]+[0-9]*)"/gim, + replace: '$1<%- version %>"', }, { - files: '.buildkite/*', - test: /image: node:[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?$/gm, - replace: 'image: node:<%- version %>$2', + id: 'docker-compose', + files: '**/docker-compose*.y*ml', + test: /(image: )(public.ecr.aws\/docker\/library\/)?(node:)[0-9.]+(\.[^- \n]+)?(-[^ \n]+)?$/gm, + replace: '$1$2$3<%- version %>$5', }, ]; -const runSubPatch = async (version: number, dir: string, patch: SubPatch) => { +const removeNodeShas = (content: string): string => + content.replace(SHA_REGEX, ''); + +type Versions = { + nodeVersion: number; + nodeTypesVersion: string; + ECMAScriptVersion: string; +}; + +const runSubPatch = async ( + { nodeVersion, nodeTypesVersion, ECMAScriptVersion }: Versions, + dir: string, + patch: SubPatch, +) => { + if (Array.isArray(patch)) { + for (const subPatch of patch) { + await runSubPatch( + { nodeVersion, nodeTypesVersion, ECMAScriptVersion }, + dir, + subPatch, + ); + } + return; + } const readFile = createDestinationFileReader(dir); const paths = patch.file ? [patch.file] @@ -60,33 +165,99 @@ const runSubPatch = async (version: number, dir: string, patch: SubPatch) => { return; } - const templated = patch.replace.replaceAll( - '<%- version %>', - version.toString(), - ); + const unPinnedContents = removeNodeShas(contents); + + if (patch.id === 'package-json-1') { + return await writePatchedContents({ + path, + contents: unPinnedContents, + templated: patch.replace.replaceAll( + '<%- version %>', + nodeTypesVersion, + ), + test: patch.test, + }); + } + if (patch.id === 'tsconfig') { + await checkSkubaType(); + return await writePatchedContents({ + path, + contents: unPinnedContents, + templated: patch.replace.replaceAll( + '<%- version %>', + ECMAScriptVersion, + ), + test: patch.test, + }); + } + + if (patch.id === 'package-json-2') { + await checkSkubaType(); + } - await fs.promises.writeFile( + await writePatchedContents({ path, - patch.test ? contents.replaceAll(patch.test, templated) : templated, - ); + contents: unPinnedContents, + templated: patch.replace.replaceAll( + '<%- version %>', + nodeVersion.toString(), + ), + test: patch.test, + }); }), ); }; -const upgrade = async (version: number, dir: string) => { +const writePatchedContents = async ({ + path, + contents, + templated, + test, +}: { + path: string; + contents: string; + templated: string; + test?: RegExp; +}) => + await fs.promises.writeFile( + path, + test ? contents.replaceAll(test, templated) : templated, + ); + +const upgrade = async ( + { nodeVersion, nodeTypesVersion, ECMAScriptVersion }: Versions, + dir: string, +) => { await Promise.all( - subPatches.map((subPatch) => runSubPatch(version, dir, subPatch)), + subPatches.map((subPatch) => + runSubPatch( + { nodeVersion, nodeTypesVersion, ECMAScriptVersion }, + dir, + subPatch, + ), + ), ); }; export const nodeVersionMigration = async ( - version: number, + { + nodeVersion, + ECMAScriptVersion, + }: { nodeVersion: number; ECMAScriptVersion: string }, dir = process.cwd(), ) => { - log.ok(`Upgrading to Node.js ${version}`); + log.ok(`Upgrading to Node.js ${nodeVersion}`); try { - await upgrade(version, dir); - log.ok('Upgraded to Node.js', version); + await checkServerlessVersion(); + const { version: nodeTypesVersion, err } = getNode22TypeVersion( + nodeVersion, + DEFAULT_NODE_TYPES, + ); + if (err) { + log.warn(err); + } + await upgrade({ nodeVersion, nodeTypesVersion, ECMAScriptVersion }, dir); + log.ok('Upgraded to Node.js', nodeVersion); } catch (err) { log.err('Failed to upgrade'); log.subtle(inspect(err)); diff --git a/src/cli/migrate/nodeVersion/packageJsonChecks.test.ts b/src/cli/migrate/nodeVersion/packageJsonChecks.test.ts new file mode 100644 index 000000000..3064ed845 --- /dev/null +++ b/src/cli/migrate/nodeVersion/packageJsonChecks.test.ts @@ -0,0 +1,103 @@ +import findUp from 'find-up'; +import fs from 'fs-extra'; + +jest.mock('find-up'); +jest.mock('fs-extra'); + +import { log } from '../../../utils/logging'; + +import { checkServerlessVersion, checkSkubaType } from './packageJsonChecks'; + +jest.spyOn(log, 'warn'); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('checkServerlessVersion', () => { + it('resolves as a noop when serverless version is supported', async () => { + jest.mocked(findUp).mockResolvedValueOnce('package.json'); + jest + .spyOn(fs, 'readFile') + .mockImplementation() + .mockReturnValue( + JSON.stringify({ + devDependencies: { + serverless: '4.0.0', + }, + }) as never, + ); + await expect(checkServerlessVersion()).resolves.toBeUndefined(); + }); + it('throws when the serverless version is below 4', async () => { + jest.mocked(findUp).mockResolvedValueOnce('package.json'); + jest + .spyOn(fs, 'readFile') + .mockImplementation() + .mockReturnValue( + JSON.stringify({ + devDependencies: { + serverless: '3.0.0', + }, + }) as never, + ); + await expect(checkServerlessVersion()).rejects.toThrow( + 'Serverless version not supported, please upgrade to 4.x', + ); + }); + it('resolves as a noop when serverless version is not found', async () => { + jest.mocked(findUp).mockResolvedValueOnce('package.json'); + jest + .spyOn(fs, 'readFile') + .mockImplementation() + .mockReturnValue(JSON.stringify({}) as never); + await expect(checkServerlessVersion()).resolves.toBeUndefined(); + }); + it('throws when no package.json is found', async () => { + jest.mocked(findUp).mockResolvedValueOnce(undefined); + await expect(checkServerlessVersion()).rejects.toThrow( + 'package.json not found', + ); + }); +}); + +describe('checkSkubaType', () => { + it('should return undefined when skuba type is not "package"', async () => { + jest.mocked(findUp).mockResolvedValueOnce('package.json'); + jest + .spyOn(fs, 'readFile') + .mockImplementation() + .mockReturnValue( + JSON.stringify({ + skuba: { + type: 'application', + }, + }) as never, + ); + await expect(checkSkubaType()).resolves.toBeUndefined(); + }); + it('should throw when skuba type is "package"', async () => { + jest.mocked(findUp).mockResolvedValueOnce('package.json'); + jest + .spyOn(fs, 'readFile') + .mockImplementation() + .mockReturnValue( + JSON.stringify({ + skuba: { + type: 'package', + }, + }) as never, + ); + await expect(checkSkubaType()).rejects.toThrow( + 'Skuba type package is not supported, packages should be updated manually to ensure major runtime depreciations are intended', + ); + }); + it('should return undefined when skuba type is not found', async () => { + jest.mocked(findUp).mockResolvedValueOnce('package.json'); + jest + .spyOn(fs, 'readFile') + .mockImplementation() + .mockReturnValue(JSON.stringify({}) as never); + await expect(checkSkubaType()).resolves.toBeUndefined(); + }); +}); diff --git a/src/cli/migrate/nodeVersion/packageJsonChecks.ts b/src/cli/migrate/nodeVersion/packageJsonChecks.ts new file mode 100644 index 000000000..cd4f31eb8 --- /dev/null +++ b/src/cli/migrate/nodeVersion/packageJsonChecks.ts @@ -0,0 +1,69 @@ +import findUp from 'find-up'; +import fs from 'fs-extra'; + +const getParentPackageJson = async () => { + const packageJsonPath = await findUp('package.json', { cwd: process.cwd() }); + if (!packageJsonPath) { + throw new Error('package.json not found'); + } + return fs.readFile(packageJsonPath); +}; + +const isTypeError = (error: unknown): error is TypeError => + error instanceof TypeError && + error.message.includes('Cannot read properties of undefined'); + +const isSyntaxError = (error: unknown): error is SyntaxError => + error instanceof SyntaxError && error.message.includes('Unexpected token'); + +export const checkServerlessVersion = async () => { + const packageJson = await getParentPackageJson(); + + try { + const serverlessVersion = ( + JSON.parse(packageJson.toString()) as { + devDependencies: Record; + } + ).devDependencies.serverless; + if (!serverlessVersion) { + return; + } + + if (!serverlessVersion.startsWith('4')) { + throw new Error( + 'Serverless version not supported, please upgrade to 4.x', + ); + } + } catch (error) { + if (isTypeError(error) || isSyntaxError(error)) { + return; + } + throw error; + } +}; + +export const checkSkubaType = async () => { + const packageJson = await getParentPackageJson(); + + try { + const type = ( + JSON.parse(packageJson.toString()) as { + skuba: Record; + } + ).skuba.type; + if (!type) { + return; + } + + if (type === 'package') { + throw new Error( + 'Skuba type package is not supported, packages should be updated manually to ensure major runtime depreciations are intended', + ); + } + } catch (error) { + if (isTypeError(error) || isSyntaxError(error)) { + return; + } + throw error; + } +};