diff --git a/package.json b/package.json index 46b694b562c..dc6ba3c9bba 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "eslint-plugin-testing-library": "6.0.2", "execa": "5.1.1", "find-up": "5.0.0", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "get-port": "5.1.1", "glob": "7.2.3", "husky": "8.0.2", diff --git a/packages/cli/create-strapi-starter/package.json b/packages/cli/create-strapi-starter/package.json index 44223c756a2..63fba6e7317 100644 --- a/packages/cli/create-strapi-starter/package.json +++ b/packages/cli/create-strapi-starter/package.json @@ -49,7 +49,7 @@ "ci-info": "3.8.0", "commander": "8.3.0", "execa": "5.1.1", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "inquirer": "8.2.5", "ora": "5.4.1" }, diff --git a/packages/core/admin/package.json b/packages/core/admin/package.json index 9add07119a2..38c6f23390a 100644 --- a/packages/core/admin/package.json +++ b/packages/core/admin/package.json @@ -103,7 +103,7 @@ "fork-ts-checker-webpack-plugin": "8.0.0", "formik": "2.4.0", "fractional-indexing": "3.2.0", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "highlight.js": "^10.4.1", "history": "^4.9.0", "html-webpack-plugin": "5.5.0", diff --git a/packages/core/content-type-builder/package.json b/packages/core/content-type-builder/package.json index cdecd8505dc..c6d5e84087d 100644 --- a/packages/core/content-type-builder/package.json +++ b/packages/core/content-type-builder/package.json @@ -64,7 +64,7 @@ "@strapi/helper-plugin": "4.15.5", "@strapi/icons": "1.13.2", "@strapi/utils": "4.15.5", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "immer": "9.0.19", "koa-bodyparser": "4.4.1", "lodash": "4.17.21", diff --git a/packages/core/data-transfer/package.json b/packages/core/data-transfer/package.json index 367bfb2db8f..fe8a5dee0d9 100644 --- a/packages/core/data-transfer/package.json +++ b/packages/core/data-transfer/package.json @@ -48,7 +48,7 @@ "chalk": "4.1.2", "cli-table3": "0.6.2", "commander": "8.3.0", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "inquirer": "8.2.5", "lodash": "4.17.21", "ora": "5.4.1", diff --git a/packages/core/database/package.json b/packages/core/database/package.json index b3d8e1e09ef..13feb8fcd65 100644 --- a/packages/core/database/package.json +++ b/packages/core/database/package.json @@ -43,7 +43,7 @@ "@strapi/utils": "4.15.5", "date-fns": "2.30.0", "debug": "4.3.4", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "knex": "3.0.1", "lodash": "4.17.21", "semver": "7.5.4", diff --git a/packages/core/strapi/package.json b/packages/core/strapi/package.json index 5ec0edf2bc4..dcaba23dd53 100644 --- a/packages/core/strapi/package.json +++ b/packages/core/strapi/package.json @@ -133,8 +133,8 @@ "delegates": "1.0.0", "dotenv": "14.2.0", "execa": "5.1.1", - "fs-extra": "10.0.0", - "glob": "7.2.3", + "fs-extra": "10.1.0", + "glob": "10.3.10", "http-errors": "1.8.1", "inquirer": "8.2.5", "is-docker": "2.2.1", diff --git a/packages/core/strapi/src/load/glob.ts b/packages/core/strapi/src/load/glob.ts deleted file mode 100644 index 02ecc729987..00000000000 --- a/packages/core/strapi/src/load/glob.ts +++ /dev/null @@ -1,15 +0,0 @@ -import glob, { IOptions } from 'glob'; - -/** - * Promise based glob - */ -function promiseGlob(...args: [string, IOptions]): Promise { - return new Promise((resolve, reject) => { - glob(...args, (err, files) => { - if (err) return reject(err); - resolve(files); - }); - }); -} - -export default promiseGlob; diff --git a/packages/core/strapi/src/load/load-files.ts b/packages/core/strapi/src/load/load-files.ts index 83cb5479034..7b06d48f6f2 100644 --- a/packages/core/strapi/src/load/load-files.ts +++ b/packages/core/strapi/src/load/load-files.ts @@ -3,7 +3,7 @@ import _ from 'lodash'; import fse from 'fs-extra'; import { importDefault } from '@strapi/utils'; -import glob from './glob'; +import { glob } from 'glob'; import filePathToPath from './filepath-to-prop-path'; /** diff --git a/packages/core/types/src/types/core/attributes/json.ts b/packages/core/types/src/types/core/attributes/json.ts index f7c3c2556b7..c4ea70c8425 100644 --- a/packages/core/types/src/types/core/attributes/json.ts +++ b/packages/core/types/src/types/core/attributes/json.ts @@ -1,3 +1,4 @@ +import type * as Utils from '../../utils'; import type { Attribute } from '..'; export type JSON = Attribute.OfType<'json'> & @@ -9,14 +10,6 @@ export type JSON = Attribute.OfType<'json'> & Attribute.VisibleOption & Attribute.DefaultOption; -type JSONValue = string | number | boolean | null | JSONObject | JSONArray; - -type JSONArray = Array; - -export interface JSONObject { - [key: string]: JSONValue; -} - -export type JsonValue = T; +export type JsonValue = T; export type GetJsonValue = T extends JSON ? JsonValue : never; diff --git a/packages/core/types/src/types/utils/index.ts b/packages/core/types/src/types/utils/index.ts index e5274c60712..c28e1afa625 100644 --- a/packages/core/types/src/types/utils/index.ts +++ b/packages/core/types/src/types/utils/index.ts @@ -6,6 +6,8 @@ export * as Function from './function'; export * as Tuple from './tuple'; export * as Expression from './expression'; +export * from './json'; + /** * Get the type of a specific key `TKey` in `TValue` * diff --git a/packages/core/types/src/types/utils/json.ts b/packages/core/types/src/types/utils/json.ts new file mode 100644 index 00000000000..278d7996bbd --- /dev/null +++ b/packages/core/types/src/types/utils/json.ts @@ -0,0 +1,7 @@ +export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; + +export type JSONArray = Array; + +export interface JSONObject { + [key: string]: JSONValue; +} diff --git a/packages/core/upload/package.json b/packages/core/upload/package.json index 83cea47ac80..788b89963e8 100644 --- a/packages/core/upload/package.json +++ b/packages/core/upload/package.json @@ -53,7 +53,7 @@ "cropperjs": "1.6.0", "date-fns": "2.30.0", "formik": "2.4.0", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "immer": "9.0.19", "koa-range": "0.3.0", "koa-static": "5.0.0", diff --git a/packages/generators/app/package.json b/packages/generators/app/package.json index db87cd4c1b6..97d9b7ee3e2 100644 --- a/packages/generators/app/package.json +++ b/packages/generators/app/package.json @@ -48,7 +48,7 @@ "@sentry/node": "6.19.7", "chalk": "^4.1.2", "execa": "5.1.1", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "inquirer": "8.2.5", "lodash": "4.17.21", "node-machine-id": "^1.1.10", diff --git a/packages/generators/generators/package.json b/packages/generators/generators/package.json index 297fd402279..0b9e42e7f74 100644 --- a/packages/generators/generators/package.json +++ b/packages/generators/generators/package.json @@ -51,7 +51,7 @@ "@strapi/utils": "4.15.5", "chalk": "4.1.2", "copyfiles": "2.4.1", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "node-plop": "0.26.3", "plop": "2.7.6", "pluralize": "8.0.0" diff --git a/packages/plugins/documentation/package.json b/packages/plugins/documentation/package.json index a86f1c69d67..fcdb3b10234 100644 --- a/packages/plugins/documentation/package.json +++ b/packages/plugins/documentation/package.json @@ -53,7 +53,7 @@ "bcryptjs": "2.4.3", "cheerio": "^1.0.0-rc.12", "formik": "2.4.0", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "immer": "9.0.19", "koa-static": "^5.0.0", "lodash": "4.17.21", diff --git a/packages/providers/upload-local/package.json b/packages/providers/upload-local/package.json index ac63d18ecab..3049904c8d4 100644 --- a/packages/providers/upload-local/package.json +++ b/packages/providers/upload-local/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@strapi/utils": "4.15.5", - "fs-extra": "10.0.0" + "fs-extra": "10.1.0" }, "devDependencies": { "@strapi/pack-up": "4.15.5", diff --git a/packages/utils/typescript/package.json b/packages/utils/typescript/package.json index e0a3c39f749..5be2b2204f1 100644 --- a/packages/utils/typescript/package.json +++ b/packages/utils/typescript/package.json @@ -37,7 +37,7 @@ "dependencies": { "chalk": "4.1.2", "cli-table3": "0.6.2", - "fs-extra": "10.0.0", + "fs-extra": "10.1.0", "lodash": "4.17.21", "prettier": "2.8.4", "typescript": "5.3.2" diff --git a/packages/utils/upgrade/.eslintignore b/packages/utils/upgrade/.eslintignore new file mode 100644 index 00000000000..acad9ee63d5 --- /dev/null +++ b/packages/utils/upgrade/.eslintignore @@ -0,0 +1,6 @@ +bin +coverage +dist +# Prevent linting error (import/no-extraneous-dependencies) +# "'@strapi/pack-up' should be listed in the project's dependencies, not devDependencies" +packup.config.ts diff --git a/packages/utils/upgrade/.eslintrc.js b/packages/utils/upgrade/.eslintrc.js new file mode 100644 index 00000000000..4d7dce86b1a --- /dev/null +++ b/packages/utils/upgrade/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + root: true, + extends: ['custom/back/typescript'], + parserOptions: { + project: ['./tsconfig.eslint.json'], + }, +}; diff --git a/packages/utils/upgrade/LICENSE b/packages/utils/upgrade/LICENSE new file mode 100644 index 00000000000..cbf83deca90 --- /dev/null +++ b/packages/utils/upgrade/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015-present Strapi Solutions SAS + +Portions of the Strapi software are licensed as follows: + +- All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE". + +- All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below. + +MIT Expat License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/utils/upgrade/README.md b/packages/utils/upgrade/README.md new file mode 100644 index 00000000000..c9bfa383684 --- /dev/null +++ b/packages/utils/upgrade/README.md @@ -0,0 +1,157 @@ +# Strapi Upgrade Tool + +## Description + +The Strapi Upgrade Tool is the CLI for facilitating upgrades between Strapi versions. + +The exact details of what level of upgrade will be run (patch, minor, major) based on the current version and what cli options will be available are still in discussion and this document will be updated once they are finalized. + +From now on, all breaking changes, and ideally also deprecations, must have an accompanying code transform written to be accepted. + +### Types of Transforms + +The upgrade tool provides two types of transforms: + +- `json`: for updating a project's .json files, primarily intended for the `package.json` +- `code`: codemods; for updating a project's .js and .ts files + +### What's a codemod? + +Codemods are a scripted way to refactor code. Here we are providing and running these scripts for users for any changes necessary in user code between Strapi versions. + +For example, if we need to rename a package used by Strapi projects, instead of instructing users to change the import, we provide a script that searches through the user's project and does the replacement for them. + +### Data Migrations + +Data migrations are not handled by the upgrade tool. + +For Strapi v4, no data migrations will be allowed and no support is planned (except in extenuating circumstances eg, a critical security issue somehow relating to the database shape) + +For Strapi v5, automated data migrations can be added in the `packages/core/database` package of the `v5/main` branch of this repo. + +## Usage + +This package is not yet released, so currently it can be run on a project in the monorepo /examples directory with the following command: + +`../../packages/utils/upgrade/bin/upgrade` + +Run the command with the `--help` option to see all the available options. + +[Coming Soon] The Strapi Upgrade tool will be available using `npx @strapi/upgrade` and an alias for that within a project using `strapi upgrade` + +## Writing a code transforms + +To begin your code transform script, create a file `upgrade/resources/transforms/{X.X.X}/{short-description-of-action}.{code|json}.ts` where `X.X.X` is the target version of Strapi the codemod will be run for. + +For example, all breaking changes for the initial release of Strapi v5 will go in upgrade/resources/transforms/5.0.0 + +Note that "short-description-of-action" will be converted to text displayed to the user with hyphens converted to spaces, for example: "short description of action" + +### 'json' transforms + +Your transform will be called for every json file in a user's project, and you must return the json object (modified or not) at the end to be passed to the next transform. + +Here is an example JSON Transform script: + +```typescript +import path from 'node:path'; + +import type { JSONTransform } from '../../..'; + +const transform: JSONTransform = (file, params) => { + // Extract the json api and the cwd so we can target specific files + const { cwd, json } = params; + + // To target only a root level package.json file: + const rootPackageJsonPath = path.join(cwd, 'package.json'); + if (file.path !== rootPackageJsonPath) { + // Return the json object unmodified to pass it to the next transform + return file.json; + } + + // Use json() to get useful helpers for performing your transform + const j = json(file.json); + + const strapiDepAddress = 'dependencies.@strapi/strapi'; + + // if this file contains a value at dependencies.@strapi/strapi + if (j.has(strapiDepAddress)) { + // we set the value to 5.0.0 + j.set(strapiDepAddress, '5.0.0'); + } + + // at the end we must return the modified json object + return j.root(); +}; + +export default transform; +``` + +For reference, these are the types for the relevant objects, which can be found in `packages/utils/upgrade/src/core/runner/json.ts`: + +```typescript +export interface JSONTransformParams { + cwd: string; + json: (object: Utils.JSONObject) => JSONTransformAPI; +} + +export interface JSONTransformAPI { + get(path?: string, defaultValue?: T): T | undefined; + has(path: string): boolean; + set(path: string, value: Utils.JSONValue): this; + merge(other: Utils.JSONObject): this; + root(): Utils.JSONObject; +} + +export type JSONTransform = (file: JSONSourceFile, params: JSONTransformParams) => Utils.JSONObject; +``` + +The methods available from `json()` are wrappers for the lodash methods of the same name: + +- **get(path, default)**: get path or default if not found +- **set(path, value)**: set path (such as 'engines.node', 'dependencies', 'author.name') to value +- **has(path)**: checks if path exists +- **merge(obj)**: merges two json objects +- **root()**: returns the whole json object + +### 'code' codemod transforms + +Codemod transforms use the [`jscodeshift`](https://github.com/facebook/jscodeshift) library to modify code passed in. Please see their documentation for advanced details. + +The `file` and `api` parameters come directly from the [jsoncodeshift arguments of the same name](https://github.com/facebook/jscodeshift#arguments). + +```typescript +import type { Transform } from 'jscodeshift'; + +const transform: Transform = (file, api) => { + // Extract the jscodeshift API + const { j } = api; + // Parse the file content + const root = j(file.source); + + root + // Find console.log calls expressions + .find(j.CallExpression, { + callee: { object: { name: 'console' }, property: { name: 'log' } }, + }) + // For each call expression + .forEach((path) => { + const { callee } = path.node; + + if ( + // Make sure the callee is a member expression (object/property) + j.MemberExpression.check(callee) && + // Make sure the property is an actual identifier (contains a name property) + j.Identifier.check(callee.property) + ) { + // Update the property's identifier name + callee.property.name = 'info'; + } + }); + + // Return the updated file content + return root.toSource(); +}; + +export default transform; +``` diff --git a/packages/utils/upgrade/bin/upgrade.js b/packages/utils/upgrade/bin/upgrade.js new file mode 100755 index 00000000000..e8ee9bf78fe --- /dev/null +++ b/packages/utils/upgrade/bin/upgrade.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('../dist/cli.js'); diff --git a/packages/utils/upgrade/jest.config.js b/packages/utils/upgrade/jest.config.js new file mode 100644 index 00000000000..45411d69e0d --- /dev/null +++ b/packages/utils/upgrade/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: '../../../jest-preset.unit.js', + displayName: 'Upgrade', + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/utils/upgrade/package.json b/packages/utils/upgrade/package.json new file mode 100644 index 00000000000..1612091fff5 --- /dev/null +++ b/packages/utils/upgrade/package.json @@ -0,0 +1,87 @@ +{ + "name": "@strapi/upgrade", + "version": "4.15.5", + "description": "CLI to upgrade Strapi applications effortless", + "keywords": [ + "strapi", + "package", + "tool", + "upgrade", + "migrate", + "version" + ], + "repository": { + "type": "git", + "url": "https://github.com/strapi/strapi.git", + "directory": "packages/utils/upgrade" + }, + "license": "SEE LICENSE IN LICENSE", + "author": { + "name": "Strapi Solutions SAS", + "email": "hi@strapi.io", + "url": "https://strapi.io" + }, + "maintainers": [ + { + "name": "Strapi Solutions SAS", + "email": "hi@strapi.io", + "url": "https://strapi.io" + } + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "bin": "./bin/upgrade.js", + "files": [ + "bin", + "dist", + "resources" + ], + "scripts": { + "build": "pack-up build", + "clean": "run -T rimraf ./dist", + "lint": "run -T eslint .", + "prepublishOnly": "yarn clean && yarn build", + "test:ts": "run -T tsc --noEmit", + "test:unit": "run -T jest", + "test:unit:watch": "run -T jest --watch", + "watch": "pack-up watch" + }, + "dependencies": { + "chalk": "4.1.2", + "cli-table3": "0.6.2", + "commander": "8.3.0", + "esbuild-register": "3.5.0", + "fs-extra": "10.1.0", + "glob": "10.3.10", + "jscodeshift": "0.15.1", + "lodash": "4.17.21", + "memfs": "4.6.0", + "ora": "5.4.1", + "prompts": "2.4.2", + "semver": "7.5.4", + "simple-git": "3.21.0" + }, + "devDependencies": { + "@strapi/pack-up": "workspace:*", + "@strapi/types": "4.15.5", + "@types/jscodeshift": "0.11.10", + "eslint-config-custom": "workspace:*", + "rimraf": "3.0.2" + }, + "engines": { + "node": ">=18.0.0 <=20.x.x", + "npm": ">=6.0.0" + } +} diff --git a/packages/utils/upgrade/packup.config.ts b/packages/utils/upgrade/packup.config.ts new file mode 100644 index 00000000000..0b24bdecfac --- /dev/null +++ b/packages/utils/upgrade/packup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@strapi/pack-up'; + +export default defineConfig({ + bundles: [ + { + source: './src/cli/index.ts', + require: './dist/cli.js', + }, + ], + runtime: 'node', + minify: false, + sourcemap: true, +}); diff --git a/packages/utils/upgrade/resources/codemods/5.0.0/.gitkeep b/packages/utils/upgrade/resources/codemods/5.0.0/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/utils/upgrade/resources/codemods/5.0.0/console.log-to-console.info.code.ts b/packages/utils/upgrade/resources/codemods/5.0.0/console.log-to-console.info.code.ts new file mode 100644 index 00000000000..ace4d32d81a --- /dev/null +++ b/packages/utils/upgrade/resources/codemods/5.0.0/console.log-to-console.info.code.ts @@ -0,0 +1,37 @@ +import type { Transform } from 'jscodeshift'; + +/** + * Note: This codemod is only for development purposes and should be deleted before releasing + */ + +const transform: Transform = (file, api) => { + // Extract the jscodeshift API + const { j } = api; + // Parse the file content + const root = j(file.source); + + root + // Find console.log calls expressions + .find(j.CallExpression, { + callee: { object: { name: 'console' }, property: { name: 'log' } }, + }) + // For each call expression + .forEach((path) => { + const { callee } = path.node; + + if ( + // Make sure the callee is a member expression (object/property) + j.MemberExpression.check(callee) && + // Make sure the property is an actual identifier (contains a name property) + j.Identifier.check(callee.property) + ) { + // Update the property's identifier name + callee.property.name = 'info'; + } + }); + + // Return the updated file content + return root.toSource(); +}; + +export default transform; diff --git a/packages/utils/upgrade/resources/codemods/5.0.0/upgrade-strapi-version.json.ts b/packages/utils/upgrade/resources/codemods/5.0.0/upgrade-strapi-version.json.ts new file mode 100644 index 00000000000..7e793c8d03a --- /dev/null +++ b/packages/utils/upgrade/resources/codemods/5.0.0/upgrade-strapi-version.json.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; + +import type { modules } from '../../..'; + +/** + * Note: This transform file is only for development purposes and should be deleted before releasing + */ + +const transform: modules.runner.json.JSONTransform = (file, params) => { + const { cwd, json } = params; + + // Ignore files that are not the root package.json + // Note: We could also find every file named package.json and update the dependencies for all of them + const rootPackageJsonPath = path.join(cwd, 'package.json'); + if (file.path !== rootPackageJsonPath) { + return file.json; + } + + const j = json(file.json); + const strapiDepAddress = 'dependencies.@strapi/strapi'; + + if (j.has(strapiDepAddress)) { + j.set(strapiDepAddress, '5.0.0'); + } + + return j.root(); +}; + +export default transform; diff --git a/packages/utils/upgrade/src/cli/commands/upgrade.ts b/packages/utils/upgrade/src/cli/commands/upgrade.ts new file mode 100644 index 00000000000..c1b269faff6 --- /dev/null +++ b/packages/utils/upgrade/src/cli/commands/upgrade.ts @@ -0,0 +1,38 @@ +import prompts from 'prompts'; + +import { loggerFactory } from '../../modules/logger'; +import { handleError } from '../errors'; +import * as tasks from '../../tasks'; + +import type { Command } from '../types'; + +export const upgrade: Command = async (options) => { + try { + const logger = loggerFactory({ silent: options.silent, debug: options.debug }); + + logger.warn( + "Please make sure you've created a backup of your codebase and files before upgrading" + ); + + await tasks.upgrade({ + logger, + confirm, + dry: options.dry, + cwd: options.projectPath, + target: options.target, + }); + } catch (err) { + handleError(err); + } +}; + +const confirm = async (message: string) => { + const { confirm } = await prompts({ + name: 'confirm', + type: 'confirm', + message, + }); + + // If confirm is undefined (Ctrl + C), default to false + return confirm ?? false; +}; diff --git a/packages/utils/upgrade/src/cli/errors.ts b/packages/utils/upgrade/src/cli/errors.ts new file mode 100644 index 00000000000..192ba37e40b --- /dev/null +++ b/packages/utils/upgrade/src/cli/errors.ts @@ -0,0 +1,9 @@ +import chalk from 'chalk'; + +export const handleError = (err: unknown) => { + console.error( + chalk.red(`[ERROR]\t[${new Date().toISOString()}]`), + err instanceof Error ? err.message : err + ); + process.exit(1); +}; diff --git a/packages/utils/upgrade/src/cli/index.ts b/packages/utils/upgrade/src/cli/index.ts new file mode 100644 index 00000000000..e6b950302d3 --- /dev/null +++ b/packages/utils/upgrade/src/cli/index.ts @@ -0,0 +1,56 @@ +import os from 'os'; +import chalk from 'chalk'; +import { program } from 'commander'; + +import { version as packageJSONVersion } from '../../package.json'; +import { Version } from '../modules/version'; + +import type { CLIOptions } from './types'; + +const addReleaseUpgradeCommand = (releaseType: Version.ReleaseType, description: string) => { + program + .command(releaseType) + .description(description) + .option('-p, --project-path ', 'Path to the Strapi project') + .option('-n, --dry', 'Simulate the upgrade without updating any files', false) + .option('-d, --debug', 'Get more logs in debug mode', false) + .option('-s, --silent', "Don't log anything", false) + .action(async (options: CLIOptions) => { + const { upgrade } = await import('./commands/upgrade.js'); + + return upgrade({ ...options, target: releaseType }); + }); +}; + +addReleaseUpgradeCommand( + Version.ReleaseType.Major, + 'Upgrade to the next available major version of Strapi' +); + +// TODO: Add back the command when adding the support for minor upgrades +// addReleaseUpgradeCommand( +// Version.ReleaseType.Minor, +// 'Upgrade to the latest minor/patch version of Strapi for the current major' +// ); + +// TODO: Add back the command when adding the support for patch upgrades +// addReleaseUpgradeCommand( +// Version.ReleaseType.Patch, +// 'Upgrade to latest patch version of Strapi for the current major and minor' +// ); + +program + .usage(' [options]') + .on('command:*', ([invalidCmd]) => { + console.error( + chalk.red( + `[ERROR] Invalid command: ${invalidCmd}.${os.EOL} See --help for a list of available commands.` + ) + ); + + process.exit(1); + }) + .helpOption('-h, --help', 'Print command line options') + .addHelpCommand('help [command]', 'Print options for a specific command') + .version(packageJSONVersion) + .parse(process.argv); diff --git a/packages/utils/upgrade/src/cli/types.ts b/packages/utils/upgrade/src/cli/types.ts new file mode 100644 index 00000000000..02621228c69 --- /dev/null +++ b/packages/utils/upgrade/src/cli/types.ts @@ -0,0 +1,16 @@ +import type { Version } from '../modules/version'; +import type { MaybePromise } from '../types'; + +export interface CLIOptions { + dry: boolean; + debug: boolean; + silent: boolean; + + projectPath?: string; +} + +export interface CommandOptions extends CLIOptions { + target: Version.ReleaseType; +} + +export type Command = (options: CommandOptions) => MaybePromise; diff --git a/packages/utils/upgrade/src/index.ts b/packages/utils/upgrade/src/index.ts new file mode 100644 index 00000000000..ce30b2162e8 --- /dev/null +++ b/packages/utils/upgrade/src/index.ts @@ -0,0 +1,2 @@ +export * as tasks from './tasks'; +export * as modules from './modules'; diff --git a/packages/utils/upgrade/src/modules/codemod-repository/constants.ts b/packages/utils/upgrade/src/modules/codemod-repository/constants.ts new file mode 100644 index 00000000000..0e915fbddc4 --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod-repository/constants.ts @@ -0,0 +1,9 @@ +import path from 'node:path'; + +export const INTERNAL_CODEMODS_DIRECTORY = path.join( + __dirname, + '..', + '..', + 'resources', + 'codemods' +); diff --git a/packages/utils/upgrade/src/modules/codemod-repository/index.ts b/packages/utils/upgrade/src/modules/codemod-repository/index.ts new file mode 100644 index 00000000000..ccaf814bf18 --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod-repository/index.ts @@ -0,0 +1,4 @@ +export type * from './types'; + +export { codemodRepositoryFactory } from './repository'; +export * as constants from './constants'; diff --git a/packages/utils/upgrade/src/modules/codemod-repository/repository.ts b/packages/utils/upgrade/src/modules/codemod-repository/repository.ts new file mode 100644 index 00000000000..e9ed36a3d2b --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod-repository/repository.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert'; +import fse from 'fs-extra'; +import semver from 'semver'; +import path from 'node:path'; + +import { codemodFactory, constants } from '../codemod'; +import { semVerFactory } from '../version'; + +import type { Codemod } from '../codemod'; +import type { Version } from '../version'; + +import type { CodemodRepository as CodemodRepositoryInterface } from './types'; + +export class CodemodRepository implements CodemodRepositoryInterface { + private groups: Record; + + private versions: Version.SemVer[]; + + public cwd: string; + + constructor(cwd: string) { + assert(fse.existsSync(cwd), `Invalid codemods directory provided "${cwd}"`); + + this.cwd = cwd; + + this.groups = {}; + this.versions = []; + } + + refresh() { + this.refreshAvailableVersions(); + this.refreshAvailableFiles(); + + return this; + } + + count(version: Version.SemVer) { + return this.findByVersion(version).length; + } + + countRange(range: Version.Range) { + return this.findByRange(range).length; + } + + exists(version: Version.SemVer) { + return version.raw in this.groups; + } + + findByRange(range: Version.Range) { + const entries = Object.entries(this.groups) as Array<[Version.LiteralSemVer, Codemod.List]>; + + return entries + .filter(([version]) => range.test(version)) + .map(([version, codemods]) => ({ + version: semVerFactory(version), + codemods, + })); + } + + findByVersion(version: Version.SemVer) { + const literalVersion = version.raw as Version.LiteralSemVer; + const codemods = this.groups[literalVersion]; + + return codemods ?? []; + } + + private refreshAvailableVersions() { + this.versions = fse + .readdirSync(this.cwd) // Only keep root directories + .filter((filename) => fse.statSync(path.join(this.cwd, filename)).isDirectory()) + // Paths should be valid semver + .filter((filename): filename is Version.LiteralSemVer => semver.valid(filename) !== null) + // Transform files names to SemVer instances + .map((version) => semVerFactory(version)) + // Sort versions in ascending order + .sort(semver.compare); + + return this; + } + + private refreshAvailableFiles() { + this.groups = {}; + + for (const version of this.versions) { + this.refreshAvailableFilesForVersion(version); + } + } + + private refreshAvailableFilesForVersion(version: Version.SemVer) { + const literalVersion = version.raw as Version.LiteralSemVer; + const versionDirectory = path.join(this.cwd, literalVersion); + + // Ignore obsolete versions + if (!fse.existsSync(versionDirectory)) { + return; + } + + this.groups[literalVersion] = fse + .readdirSync(versionDirectory) + // Make sure the filenames are valid codemod files + .filter((filename) => fse.statSync(path.join(versionDirectory, filename)).isFile()) + .filter((filename) => constants.CODEMOD_FILE_REGEXP.test(filename)) + // Transform the filenames into Codemod instances + .map((filename) => { + const kind = parseCodemodKindFromFilename(filename); + const baseDirectory = this.cwd; + + return codemodFactory({ kind, baseDirectory, version, filename }); + }); + } +} + +export const parseCodemodKindFromFilename = (filename: string): Codemod.Kind => { + const kind = filename.split('.').at(-2) as Codemod.Kind | undefined; + + assert(kind !== undefined); + assert(constants.CODEMOD_ALLOWED_SUFFIXES.includes(kind)); + + return kind; +}; + +export const codemodRepositoryFactory = (cwd: string) => new CodemodRepository(cwd); diff --git a/packages/utils/upgrade/src/modules/codemod-repository/types.ts b/packages/utils/upgrade/src/modules/codemod-repository/types.ts new file mode 100644 index 00000000000..c509fb80068 --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod-repository/types.ts @@ -0,0 +1,16 @@ +import type { Codemod } from '../codemod'; +import type { Version } from '../version'; + +export interface CodemodRepository { + cwd: string; + + refresh(): this; + + findByRange(range: Version.Range): Codemod.VersionedCollection[]; + findByVersion(version: Version.SemVer): Codemod.List; + + exists(version: Version.SemVer): boolean; + + count(version: Version.SemVer): number; + countRange(range: Version.Range): number; +} diff --git a/packages/utils/upgrade/src/modules/codemod/codemod.ts b/packages/utils/upgrade/src/modules/codemod/codemod.ts new file mode 100644 index 00000000000..9d4702fa7ef --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod/codemod.ts @@ -0,0 +1,41 @@ +import path from 'node:path'; + +import * as constants from './constants'; + +import type { Codemod as CodemodInterface, Kind } from './types'; +import type { Version } from '../version'; + +type CreateCodemodPayload = Pick< + CodemodInterface, + 'kind' | 'version' | 'baseDirectory' | 'filename' +>; + +export class Codemod implements CodemodInterface { + kind: Kind; + + version: Version.SemVer; + + baseDirectory: string; + + filename: string; + + path: string; + + constructor(options: CreateCodemodPayload) { + this.kind = options.kind; + this.version = options.version; + this.baseDirectory = options.baseDirectory; + this.filename = options.filename; + + this.path = path.join(this.baseDirectory, this.version.raw, this.filename); + } + + format() { + return this.filename + .replace(`.${constants.CODEMOD_CODE_SUFFIX}.${constants.CODEMOD_EXTENSION}`, '') + .replace(`.${constants.CODEMOD_JSON_SUFFIX}.${constants.CODEMOD_EXTENSION}`, '') + .replaceAll('-', ' '); + } +} + +export const codemodFactory = (options: CreateCodemodPayload) => new Codemod(options); diff --git a/packages/utils/upgrade/src/modules/codemod/constants.ts b/packages/utils/upgrade/src/modules/codemod/constants.ts new file mode 100644 index 00000000000..b17235dc65c --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod/constants.ts @@ -0,0 +1,11 @@ +export const CODEMOD_CODE_SUFFIX = 'code'; + +export const CODEMOD_JSON_SUFFIX = 'json'; + +export const CODEMOD_ALLOWED_SUFFIXES = [CODEMOD_CODE_SUFFIX, CODEMOD_JSON_SUFFIX]; + +export const CODEMOD_EXTENSION = 'ts'; + +export const CODEMOD_FILE_REGEXP = new RegExp( + `^.+[.](${CODEMOD_ALLOWED_SUFFIXES.join('|')})[.]${CODEMOD_EXTENSION}$` +); diff --git a/packages/utils/upgrade/src/modules/codemod/index.ts b/packages/utils/upgrade/src/modules/codemod/index.ts new file mode 100644 index 00000000000..008d9be945e --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod/index.ts @@ -0,0 +1,4 @@ +export type * as Codemod from './types'; + +export { codemodFactory } from './codemod'; +export * as constants from './constants'; diff --git a/packages/utils/upgrade/src/modules/codemod/types.ts b/packages/utils/upgrade/src/modules/codemod/types.ts new file mode 100644 index 00000000000..2a98f453e71 --- /dev/null +++ b/packages/utils/upgrade/src/modules/codemod/types.ts @@ -0,0 +1,23 @@ +import type { Version } from '../version'; + +export type Kind = 'code' | 'json'; + +export interface Codemod { + kind: Kind; + version: Version.SemVer; + baseDirectory: string; + filename: string; + path: string; + + /** + * Return a formatted version of the codemod name + */ + format(): string; +} + +export type List = Codemod[]; + +export interface VersionedCollection { + version: Version.SemVer; + codemods: List; +} diff --git a/packages/utils/upgrade/src/modules/error/index.ts b/packages/utils/upgrade/src/modules/error/index.ts new file mode 100644 index 00000000000..04bca77e0de --- /dev/null +++ b/packages/utils/upgrade/src/modules/error/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/packages/utils/upgrade/src/modules/error/utils.ts b/packages/utils/upgrade/src/modules/error/utils.ts new file mode 100644 index 00000000000..d60dd60a134 --- /dev/null +++ b/packages/utils/upgrade/src/modules/error/utils.ts @@ -0,0 +1,17 @@ +export class UnexpectedError extends Error { + constructor() { + super('Unexpected Error'); + } +} + +export const unknownToError = (e: unknown): Error => { + if (e instanceof Error) { + return e; + } + + if (typeof e === 'string') { + return new Error(e); + } + + return new UnexpectedError(); +}; diff --git a/packages/utils/upgrade/src/modules/file-scanner/__tests__/scanner.test.ts b/packages/utils/upgrade/src/modules/file-scanner/__tests__/scanner.test.ts new file mode 100644 index 00000000000..62110edebc5 --- /dev/null +++ b/packages/utils/upgrade/src/modules/file-scanner/__tests__/scanner.test.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import { vol, fs } from 'memfs'; + +jest.mock('fs', () => fs); + +// eslint-disable-next-line import/first +import { fileScannerFactory } from '../scanner'; + +const FILES = { + 'a.ts': 'console.log("a.ts");', + 'b.mjs': 'console.log("a.ts");', + 'c.js': 'console.log("a.ts");', + 'd.js': 'console.log("a.ts");', + 'e.json': 'console.log("a.ts");', + '.gitignore': 'console.log("a.ts");', +}; + +const cwd = '/__tests__'; +const prefixed = (filename: string) => path.join(cwd, filename); + +describe('Scanner', () => { + beforeEach(() => { + vol.reset(); + vol.fromJSON(FILES, cwd); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('Scan returns an empty list for empty patterns', () => { + const scanner = fileScannerFactory(cwd); + const files = scanner.scan([]); + + expect(files).toHaveLength(0); + expect(files).toStrictEqual([]); + }); + + test.each([ + [['*.js'], ['c.js', 'd.js']], + [['*.ts'], ['a.ts']], + [['*.{js,json}'], ['c.js', 'd.js', 'e.json']], + [ + ['*.{js,json}', '.gitignore'], + ['c.js', 'd.js', 'e.json', '.gitignore'], + ], + ])('Scan returns a list of files matching %s', (patterns, expected) => { + const scanner = fileScannerFactory(cwd); + const files = scanner.scan(patterns); + + expect(files).toHaveLength(expected.length); + expect(files).toStrictEqual(expect.arrayContaining(expected.map(prefixed))); + }); +}); diff --git a/packages/utils/upgrade/src/modules/file-scanner/index.ts b/packages/utils/upgrade/src/modules/file-scanner/index.ts new file mode 100644 index 00000000000..24094c1f0b6 --- /dev/null +++ b/packages/utils/upgrade/src/modules/file-scanner/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; + +export { fileScannerFactory } from './scanner'; diff --git a/packages/utils/upgrade/src/modules/file-scanner/scanner.ts b/packages/utils/upgrade/src/modules/file-scanner/scanner.ts new file mode 100644 index 00000000000..85181131d8c --- /dev/null +++ b/packages/utils/upgrade/src/modules/file-scanner/scanner.ts @@ -0,0 +1,21 @@ +import path from 'node:path'; +import { glob } from 'glob'; + +import type { FileScanner as FileScannerInterface } from './types'; + +export class FileScanner implements FileScannerInterface { + public cwd: string; + + constructor(cwd: string) { + this.cwd = cwd; + } + + scan(patterns: string[]) { + const filenames = glob.sync(patterns, { cwd: this.cwd }); + + // Resolve the full paths for every filename + return filenames.map((filename) => path.join(this.cwd, filename)); + } +} + +export const fileScannerFactory = (cwd: string) => new FileScanner(cwd); diff --git a/packages/utils/upgrade/src/modules/file-scanner/types.ts b/packages/utils/upgrade/src/modules/file-scanner/types.ts new file mode 100644 index 00000000000..28bf24f9295 --- /dev/null +++ b/packages/utils/upgrade/src/modules/file-scanner/types.ts @@ -0,0 +1,5 @@ +export interface FileScanner { + cwd: string; + + scan(patterns: string[]): string[]; +} diff --git a/packages/utils/upgrade/src/modules/format/formats.ts b/packages/utils/upgrade/src/modules/format/formats.ts new file mode 100644 index 00000000000..2dc872ef343 --- /dev/null +++ b/packages/utils/upgrade/src/modules/format/formats.ts @@ -0,0 +1,60 @@ +import CliTable3 from 'cli-table3'; +import chalk from 'chalk'; + +import { constants as timerConstants } from '../timer'; + +import type { Version } from '../version'; +import type { Report } from '../report'; +import { isSemVer } from '../version'; + +export const path = (path: string) => chalk.blue(path); + +export const version = (version: Version.LiteralVersion | Version.SemVer) => { + return chalk.italic.yellow(isSemVer(version) ? version.raw : version); +}; + +export const versionRange = (range: string) => chalk.bold.green(range); + +export const transform = (transformFilePath: string) => chalk.cyan(transformFilePath); + +export const highlight = (text: string) => chalk.bold.underline(text); + +export const reports = (reports: Report.CodemodReport[]) => { + const rows = reports.map(({ codemod, report }, i) => { + const fIndex = chalk.grey(i); + const fVersion = chalk.magenta(codemod.version); + const fKind = chalk.yellow(codemod.kind); + const fFormattedTransformPath = chalk.cyan(codemod.format()); + const fTimeElapsed = + i === 0 + ? `${report.timeElapsed}s ${chalk.dim.italic('(cold start)')}` + : `${report.timeElapsed}s`; + const fAffected = report.ok > 0 ? chalk.green(report.ok) : chalk.grey(0); + const fUnchanged = report.ok === 0 ? chalk.red(report.nochange) : chalk.grey(report.nochange); + + return [fIndex, fVersion, fKind, fFormattedTransformPath, fAffected, fUnchanged, fTimeElapsed]; + }); + + const table = new CliTable3({ + style: { compact: true }, + head: [ + chalk.bold.grey('N°'), + chalk.bold.magenta('Version'), + chalk.bold.yellow('Kind'), + chalk.bold.cyan('Name'), + chalk.bold.green('Affected'), + chalk.bold.red('Unchanged'), + chalk.bold.blue('Duration'), + ], + }); + + table.push(...rows); + + return table.toString(); +}; + +export const durationMs = (elapsedMs: number) => { + const elapsedSeconds = (elapsedMs / timerConstants.ONE_SECOND_MS).toFixed(3); + + return `${elapsedSeconds}s`; +}; diff --git a/packages/utils/upgrade/src/modules/format/index.ts b/packages/utils/upgrade/src/modules/format/index.ts new file mode 100644 index 00000000000..57c29f167f8 --- /dev/null +++ b/packages/utils/upgrade/src/modules/format/index.ts @@ -0,0 +1 @@ +export * from './formats'; diff --git a/packages/utils/upgrade/src/modules/index.ts b/packages/utils/upgrade/src/modules/index.ts new file mode 100644 index 00000000000..698d8cf37bc --- /dev/null +++ b/packages/utils/upgrade/src/modules/index.ts @@ -0,0 +1,13 @@ +export * as codemod from './codemod'; +export * as codemodRepository from './codemod-repository'; +export * as error from './error'; +export * as fileScanner from './file-scanner'; +export * as f from './format'; +export * as logger from './logger'; +export * as project from './project'; +export * as report from './report'; +export * as requirement from './requirement'; +export * as runner from './runner'; +export * as timer from './timer'; +export * as upgrader from './upgrader'; +export * as version from './version'; diff --git a/packages/utils/upgrade/src/modules/logger/index.ts b/packages/utils/upgrade/src/modules/logger/index.ts new file mode 100644 index 00000000000..b6373e5bd23 --- /dev/null +++ b/packages/utils/upgrade/src/modules/logger/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; + +export { loggerFactory } from './logger'; diff --git a/packages/utils/upgrade/src/modules/logger/logger.ts b/packages/utils/upgrade/src/modules/logger/logger.ts new file mode 100644 index 00000000000..16a81d14dfe --- /dev/null +++ b/packages/utils/upgrade/src/modules/logger/logger.ts @@ -0,0 +1,95 @@ +import chalk from 'chalk'; + +import type { Logger as LoggerInterface, LoggerOptions } from './types'; + +export class Logger implements LoggerInterface { + isDebug: boolean; + + isSilent: boolean; + + private nbErrorsCalls: number; + + private nbWarningsCalls: number; + + constructor(options: LoggerOptions = {}) { + // Set verbosity options + this.isDebug = options.debug ?? false; + this.isSilent = options.silent ?? false; + + // Initialize counters + this.nbErrorsCalls = 0; + this.nbWarningsCalls = 0; + } + + private get isNotSilent(): boolean { + return !this.isSilent; + } + + get errors(): number { + return this.nbErrorsCalls; + } + + get warnings(): number { + return this.nbWarningsCalls; + } + + setDebug(debug: boolean): this { + this.isDebug = debug; + return this; + } + + setSilent(silent: boolean): this { + this.isSilent = silent; + return this; + } + + debug(...args: unknown[]): this { + const isDebugEnabled = this.isNotSilent && this.isDebug; + + if (isDebugEnabled) { + console.log(chalk.cyan(`[DEBUG]\t[${nowAsISO()}]`), ...args); + } + + return this; + } + + error(...args: unknown[]): this { + this.nbErrorsCalls += 1; + + if (this.isNotSilent) { + console.error(chalk.red(`[ERROR]\t[${nowAsISO()}]`), ...args); + } + + return this; + } + + info(...args: unknown[]): this { + if (this.isNotSilent) { + console.info(chalk.blue(`[INFO]\t[${new Date().toISOString()}]`), ...args); + } + + return this; + } + + raw(...args: unknown[]): this { + if (this.isNotSilent) { + console.log(...args); + } + + return this; + } + + warn(...args: unknown[]): this { + this.nbWarningsCalls += 1; + + if (this.isNotSilent) { + console.warn(chalk.yellow(`[WARN]\t[${new Date().toISOString()}]`), ...args); + } + + return this; + } +} + +const nowAsISO = () => new Date().toISOString(); + +export const loggerFactory = (options: LoggerOptions = {}) => new Logger(options); diff --git a/packages/utils/upgrade/src/modules/logger/types.ts b/packages/utils/upgrade/src/modules/logger/types.ts new file mode 100644 index 00000000000..ba4f815d2b7 --- /dev/null +++ b/packages/utils/upgrade/src/modules/logger/types.ts @@ -0,0 +1,22 @@ +export interface LoggerOptions { + silent?: boolean; + debug?: boolean; +} + +export interface Logger { + isSilent: boolean; + isDebug: boolean; + + setSilent(silent: boolean): this; + setDebug(enabled: boolean): this; + + get warnings(): number; + get errors(): number; + + debug(...args: unknown[]): this; + info(...args: unknown[]): this; + warn(...args: unknown[]): this; + error(...args: unknown[]): this; + + raw(...args: unknown[]): this; +} diff --git a/packages/utils/upgrade/src/modules/npm/constants.ts b/packages/utils/upgrade/src/modules/npm/constants.ts new file mode 100644 index 00000000000..f18981e8eae --- /dev/null +++ b/packages/utils/upgrade/src/modules/npm/constants.ts @@ -0,0 +1 @@ +export const NPM_REGISTRY_URL = 'https://registry.npmjs.org'; diff --git a/packages/utils/upgrade/src/modules/npm/index.ts b/packages/utils/upgrade/src/modules/npm/index.ts new file mode 100644 index 00000000000..1a61be18aa9 --- /dev/null +++ b/packages/utils/upgrade/src/modules/npm/index.ts @@ -0,0 +1,4 @@ +export type * as NPM from './types'; + +export { npmPackageFactory } from './package'; +export * as constants from './constants'; diff --git a/packages/utils/upgrade/src/modules/npm/package.ts b/packages/utils/upgrade/src/modules/npm/package.ts new file mode 100644 index 00000000000..17c7a1e79c9 --- /dev/null +++ b/packages/utils/upgrade/src/modules/npm/package.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert'; +import semver from 'semver'; + +import * as constants from './constants'; +import { isLiteralSemVer } from '../version'; + +import type { Package as PackageInterface, NPMPackage } from './types'; +import type { Version } from '../version'; + +export class Package implements PackageInterface { + name: string; + + packageURL: string; + + private npmPackage: NPMPackage | null; + + constructor(name: string) { + this.name = name; + this.packageURL = `${constants.NPM_REGISTRY_URL}/${name}`; + this.npmPackage = null; + } + + get isLoaded() { + return this.npmPackage !== null; + } + + private assertPackageIsLoaded(npmPackage: NPMPackage | null): asserts npmPackage is NPMPackage { + assert(this.isLoaded, 'The package is not loaded yet'); + } + + getVersionsDict() { + this.assertPackageIsLoaded(this.npmPackage); + + return this.npmPackage.versions; + } + + getVersionsAsList() { + this.assertPackageIsLoaded(this.npmPackage); + + return Object.values(this.npmPackage.versions); + } + + findVersionsInRange(range: Version.Range) { + const versions = this.getVersionsAsList(); + + return ( + versions + // Only select versions matching the upgrade range + .filter((v) => range.test(v.version)) + // Only select supported version format (x.x.x) + .filter((v) => isLiteralSemVer(v.version)) + // Sort in ascending order + .sort((v1, v2) => semver.compare(v1.version, v2.version)) + ); + } + + async refresh() { + const response = await fetch(this.packageURL); + + // TODO: Use a validation library to make sure the response structure is correct + assert(response.ok, `Request failed for ${this.packageURL}`); + + this.npmPackage = await response.json(); + + return this; + } + + versionExists(version: Version.SemVer) { + const versions = this.getVersionsAsList(); + const searchResult = versions.find((npmVersion) => semver.eq(npmVersion.version, version)); + + return searchResult !== undefined; + } +} + +export const npmPackageFactory = (name: string) => new Package(name); diff --git a/packages/utils/upgrade/src/modules/npm/types.ts b/packages/utils/upgrade/src/modules/npm/types.ts new file mode 100644 index 00000000000..853140b7965 --- /dev/null +++ b/packages/utils/upgrade/src/modules/npm/types.ts @@ -0,0 +1,119 @@ +import type { Version } from '../version'; + +type NPMVersion = string; +type ISOString = string; + +export interface Package { + name: string; + + get isLoaded(): boolean; + + refresh(): Promise; + + versionExists(version: Version.SemVer): boolean; + + getVersionsDict(): Record; + getVersionsAsList(): NPMPackageVersion[]; + + findVersionsInRange(range: Version.Range): NPMPackageVersion[]; +} + +export interface NPMPackage { + _id: string; + _rev: string; + name: string; + description: string; + homepage: string; + keywords: string[]; + license: string; + readme: string; + readmeFilename: string; + repository: PackageRepository; + author: PackageAuthor; + bugs: PackageBugs; + distTags: PackageDistTags; + versions: Record; + time: PackageTime; + maintainers: PackageMaintainer[]; +} + +export interface NPMPackageVersion { + _id: string; + _nodeVersion: string; + _npmVersion: string; + name: string; + version: NPMVersion; + description: string; + homepage: string; + main: string; + license: string; + keywords: string[]; + gitHead: string; + bin: Record; + dependencies: Record; + scripts: Record; + author: PackageAuthor; + maintainers: PackageMaintainer[]; + repository: PackageRepository; + bugs: PackageBugs; + engines: Record; + dist: Dist; + _npmUser: NpmUser; + _npmOperationalInternal: NpmOperationalInternal; + _hasShrinkwrap: boolean; +} + +export interface Dist { + integrity: string; + shasum: string; + tarball: string; + fileCount: number; + unpackedSize: number; + 'npm-signature': string; + signatures: Signature[]; +} + +interface Signature { + keyid: string; + sig: string; +} + +interface NpmUser { + name: string; + email: string; +} + +interface NpmOperationalInternal { + host: string; + tmp: string; +} + +export interface PackageDistTags { + [key: string]: NPMVersion; +} + +export interface PackageTime { + created: ISOString; + modified: ISOString; + [key: NPMVersion]: ISOString; +} + +export interface PackageRepository { + type: string; + url: string; +} + +export interface PackageMaintainer { + type: string; + url: string; +} + +export interface PackageAuthor { + name: string; + email: string; + url: string; +} + +export interface PackageBugs { + url: string; +} diff --git a/packages/utils/upgrade/src/modules/project/__tests__/project.test.ts b/packages/utils/upgrade/src/modules/project/__tests__/project.test.ts new file mode 100644 index 00000000000..61845e724ae --- /dev/null +++ b/packages/utils/upgrade/src/modules/project/__tests__/project.test.ts @@ -0,0 +1,181 @@ +import path from 'node:path'; +import { vol, fs } from 'memfs'; + +jest.mock('fs', () => fs); + +const srcFilename = (cwd: string, filename: string) => path.join(cwd, 'src', filename); +const srcFilenames = (cwd: string) => { + return Object.keys(srcFiles).map((filename) => srcFilename(cwd, filename)); +}; + +const currentStrapiVersion = '1.2.3'; + +const defaultCWD = '/__unit_tests__'; + +const packageJSONFile = `{ + "name": "test", + "version": "1.0.0", + "dependencies": { "@strapi/strapi": "${currentStrapiVersion}" } +}`; + +const srcFiles = { + 'a.ts': 'console.log("a.ts")', + 'b.ts': 'console.log("b.ts")', + 'c.js': 'console.log("c.js")', + 'd.json': `{ "foo": "bar", "bar": 123 }`, +}; + +const defaultVolume = { 'package.json': packageJSONFile, src: srcFiles }; + +// eslint-disable-next-line import/first +import { projectFactory } from '../project'; + +describe('Project', () => { + beforeEach(() => { + vol.reset(); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + describe('Factory', () => { + test('Fails on invalid project path', async () => { + vol.fromNestedJSON(defaultVolume, defaultCWD); + + const cwd = 'unknown-path'; + + expect(() => projectFactory(cwd)).toThrow( + `ENOENT: no such file or directory, access 'unknown-path'` + ); + }); + + test('Fails on project without package.json file', async () => { + vol.fromNestedJSON({ src: srcFiles }, defaultCWD); + + expect(() => projectFactory(defaultCWD)).toThrow( + `Could not find a package.json file in ${defaultCWD}` + ); + }); + + test('Fails on project without a @strapi/strapi dependency', async () => { + vol.fromNestedJSON( + { 'package.json': `{ "name": "test", "version": "1.2.3" }`, src: srcFiles }, + defaultCWD + ); + + expect(() => projectFactory(defaultCWD)).toThrow( + 'No version of @strapi/strapi was found in test. Are you in a valid Strapi project?' + ); + }); + + test(`Use the @strapi/strapi's package.json version as a fallback fails when no version is installed`, () => { + vol.fromNestedJSON( + { + 'package.json': `{ "name": "test", "version": "1.2.3", "dependencies": { "@strapi/strapi": "^4.0.0" } }`, + src: srcFiles, + }, + defaultCWD + ); + + expect(() => projectFactory(defaultCWD)).toThrow( + `Cannot resolve module "@strapi/strapi" from paths [${defaultCWD}]` + ); + }); + + // TODO: Waiting for https://github.com/jestjs/jest/issues/9543 to be implemented as we rely on require.resolve to find the actual module + test.todo(`Use the @strapi/strapi's package.json version as a fallback succeed`); + + test('Succeed for valid project', () => { + vol.fromNestedJSON(defaultVolume, defaultCWD); + + const project = projectFactory(defaultCWD); + + expect(project.files.length).toBe(5); + expect(project.files).toStrictEqual( + expect.arrayContaining([path.join(defaultCWD, 'package.json'), ...srcFilenames(defaultCWD)]) + ); + + expect(project.cwd).toBe(defaultCWD); + + expect(project.strapiVersion.raw).toBe(currentStrapiVersion); + }); + }); + + describe('refresh', () => { + test('Succeed for valid project', () => { + vol.fromNestedJSON(defaultVolume, defaultCWD); + + const project = projectFactory(defaultCWD); + + project.refresh(); + + expect(project.files.length).toBe(5); + expect(project.files).toStrictEqual( + expect.arrayContaining([path.join(defaultCWD, 'package.json'), ...srcFilenames(defaultCWD)]) + ); + + expect(project.cwd).toBe(defaultCWD); + + expect(project.strapiVersion.raw).toBe(currentStrapiVersion); + + project.packageJSON.name = 'test'; + }); + }); + + describe('runCodemods', () => {}); + + describe('getFilesByExtensions', () => { + beforeEach(() => { + vol.fromNestedJSON(defaultVolume, defaultCWD); + }); + + test('Get .js files only', () => { + const project = projectFactory(defaultCWD); + + const jsFiles = project.getFilesByExtensions(['.js']); + + expect(jsFiles).toStrictEqual(expect.arrayContaining([srcFilename(defaultCWD, 'c.js')])); + }); + + test('Get .ts files only', () => { + const project = projectFactory(defaultCWD); + + const tsFiles = project.getFilesByExtensions(['.ts']); + + expect(tsFiles).toStrictEqual( + expect.arrayContaining([srcFilename(defaultCWD, 'a.ts'), srcFilename(defaultCWD, 'b.ts')]) + ); + }); + + test('Get both .js and .ts files', () => { + const project = projectFactory(defaultCWD); + + const jsAndTSFiles = project.getFilesByExtensions(['.ts', '.js']); + + expect(jsAndTSFiles).toStrictEqual( + expect.arrayContaining([ + srcFilename(defaultCWD, 'a.ts'), + srcFilename(defaultCWD, 'b.ts'), + srcFilename(defaultCWD, 'c.js'), + ]) + ); + }); + + test('Get both .ts .json files', () => { + const project = projectFactory(defaultCWD); + + const tsAndJSONFiles = project.getFilesByExtensions(['.ts', '.json']); + + expect(tsAndJSONFiles).toStrictEqual( + expect.arrayContaining([ + path.join(defaultCWD, 'package.json'), + srcFilename(defaultCWD, 'a.ts'), + srcFilename(defaultCWD, 'b.ts'), + srcFilename(defaultCWD, 'd.json'), + ]) + ); + }); + }); +}); diff --git a/packages/utils/upgrade/src/modules/project/constants.ts b/packages/utils/upgrade/src/modules/project/constants.ts new file mode 100644 index 00000000000..262d0c9bb47 --- /dev/null +++ b/packages/utils/upgrade/src/modules/project/constants.ts @@ -0,0 +1,9 @@ +export const PROJECT_PACKAGE_JSON = 'package.json'; + +export const PROJECT_DEFAULT_ALLOWED_ROOT_PATHS = ['src', 'config', 'public']; + +export const PROJECT_DEFAULT_ALLOWED_EXTENSIONS = ['js', 'ts', 'json']; + +export const PROJECT_DEFAULT_PATTERNS = ['package.json']; + +export const STRAPI_DEPENDENCY_NAME = '@strapi/strapi'; diff --git a/packages/utils/upgrade/src/modules/project/index.ts b/packages/utils/upgrade/src/modules/project/index.ts new file mode 100644 index 00000000000..51aaaa1557a --- /dev/null +++ b/packages/utils/upgrade/src/modules/project/index.ts @@ -0,0 +1,4 @@ +export type * from './types'; + +export { projectFactory } from './project'; +export * as constants from './constants'; diff --git a/packages/utils/upgrade/src/modules/project/project.ts b/packages/utils/upgrade/src/modules/project/project.ts new file mode 100644 index 00000000000..d4f1dff2775 --- /dev/null +++ b/packages/utils/upgrade/src/modules/project/project.ts @@ -0,0 +1,187 @@ +import path from 'node:path'; +import assert from 'node:assert'; +import fse from 'fs-extra'; +import semver from 'semver'; + +import { semVerFactory, isLiteralSemVer } from '../version'; +import { fileScannerFactory } from '../file-scanner'; +import { codeRunnerFactory } from '../runner/code'; +import { jsonRunnerFactory } from '../runner/json'; +import * as constants from './constants'; + +import type { Version } from '../version'; +import type { Codemod } from '../codemod'; +import type { Report } from '../report'; +import type { + Project as ProjectInterface, + FileExtension, + MinimalPackageJSON, + RunCodemodsOptions, +} from './types'; + +export class Project implements ProjectInterface { + public cwd: string; + + // The following properties are assigned during the .refresh() call in the constructor. + + public files!: string[]; + + public packageJSON!: MinimalPackageJSON; + + public strapiVersion!: Version.SemVer; + + constructor(cwd: string) { + if (!fse.pathExistsSync(cwd)) { + throw new Error(`ENOENT: no such file or directory, access '${cwd}'`); + } + + this.cwd = cwd; + + this.refresh(); + } + + getFilesByExtensions(extensions: FileExtension[]) { + return this.files.filter((filePath) => { + const fileExtension = path.extname(filePath) as FileExtension; + + return extensions.includes(fileExtension); + }); + } + + refresh() { + this.refreshPackageJSON(); + this.refreshStrapiVersion(); + this.refreshProjectFiles(); + + return this; + } + + async runCodemods(codemods: Codemod.List, options: RunCodemodsOptions) { + const runners = this.createProjectCodemodsRunners(options.dry); + const reports: Report.CodemodReport[] = []; + + for (const codemod of codemods) { + for (const runner of runners) { + if (runner.valid(codemod)) { + const report = await runner.run(codemod); + reports.push({ codemod, report }); + } + } + } + + return reports; + } + + private createProjectCodemodsRunners(dry: boolean = false) { + const jsonFiles = this.getFilesByExtensions(['.json']); + const codeFiles = this.getFilesByExtensions(['.js', '.ts', '.mjs']); + + const codeRunner = codeRunnerFactory(codeFiles, { + dry, + print: false, + silent: true, + extensions: 'js,ts', + runInBand: true, + verbose: 0, + babel: true, + }); + const jsonRunner = jsonRunnerFactory(jsonFiles, { dry, cwd: this.cwd }); + + return [codeRunner, jsonRunner] as const; + } + + private refreshPackageJSON(): void { + const packagePath = path.join(this.cwd, constants.PROJECT_PACKAGE_JSON); + + try { + fse.accessSync(packagePath); + } catch { + throw new Error(`Could not find a ${constants.PROJECT_PACKAGE_JSON} file in ${this.cwd}`); + } + + const packageJSONBuffer = fse.readFileSync(packagePath); + + this.packageJSON = JSON.parse(packageJSONBuffer.toString()); + } + + private refreshProjectFiles(): void { + const allowedRootPaths = formatGlobCollectionPattern( + constants.PROJECT_DEFAULT_ALLOWED_ROOT_PATHS + ); + + const allowedExtensions = formatGlobCollectionPattern( + constants.PROJECT_DEFAULT_ALLOWED_EXTENSIONS + ); + + const projectFilesPattern = `./${allowedRootPaths}/**/*.${allowedExtensions}`; + + const patterns = [projectFilesPattern, ...constants.PROJECT_DEFAULT_PATTERNS]; + const scanner = fileScannerFactory(this.cwd); + + this.files = scanner.scan(patterns); + } + + private refreshStrapiVersion(): void { + this.strapiVersion = + // First try to get the strapi version from the package.json dependencies + this.findStrapiVersionFromProjectPackageJSON() ?? + // If the version found is not a valid SemVer, get the Strapi version from the installed package + this.findLocallyInstalledStrapiVersion(); + } + + private findStrapiVersionFromProjectPackageJSON(): Version.SemVer | undefined { + const projectName = this.packageJSON.name; + const version = this.packageJSON.dependencies?.[constants.STRAPI_DEPENDENCY_NAME]; + + if (version === undefined) { + throw new Error( + `No version of ${constants.STRAPI_DEPENDENCY_NAME} was found in ${projectName}. Are you in a valid Strapi project?` + ); + } + + const isValidSemVer = isLiteralSemVer(version) && semver.valid(version) === version; + + // We return undefined only if a strapi/strapi version is found, but it's not semver compliant + return isValidSemVer ? semVerFactory(version) : undefined; + } + + private findLocallyInstalledStrapiVersion(): Version.SemVer { + const packageSearchText = `${constants.STRAPI_DEPENDENCY_NAME}/package.json`; + + let strapiPackageJSONPath: string; + let strapiPackageJSON: MinimalPackageJSON; + + try { + strapiPackageJSONPath = require.resolve(packageSearchText, { paths: [this.cwd] }); + strapiPackageJSON = require(strapiPackageJSONPath); + + assert(typeof strapiPackageJSON === 'object'); + } catch { + throw new Error( + `Cannot resolve module "${constants.STRAPI_DEPENDENCY_NAME}" from paths [${this.cwd}]` + ); + } + + const strapiVersion = strapiPackageJSON.version; + const isValidSemVer = isLiteralSemVer(strapiVersion); + + if (!isValidSemVer) { + throw new Error( + `Invalid ${constants.STRAPI_DEPENDENCY_NAME} version found in ${strapiPackageJSONPath} (${strapiVersion})` + ); + } + + return semVerFactory(strapiVersion); + } +} + +const formatGlobCollectionPattern = (collection: string[]): string => { + assert( + collection.length > 0, + 'Invalid pattern provided, the given collection needs at least 1 element' + ); + + return collection.length === 1 ? collection[0] : `{${collection}}`; +}; + +export const projectFactory = (cwd: string) => new Project(cwd); diff --git a/packages/utils/upgrade/src/modules/project/types.ts b/packages/utils/upgrade/src/modules/project/types.ts new file mode 100644 index 00000000000..6e87a1b72bb --- /dev/null +++ b/packages/utils/upgrade/src/modules/project/types.ts @@ -0,0 +1,33 @@ +import type { Version } from '../version'; +import type { Codemod } from '../codemod'; +import type { Report } from '../report'; + +export type FileExtension = `.${string}`; + +export interface RunCodemodsOptions { + dry?: boolean; + onCodemodStartRunning?(codemod: Codemod.Codemod, index: number): Promise | void; + onCodemodFinishRunning?( + codemod: Codemod.Codemod, + index: number, + report: Report.Report + ): Promise | void; +} + +export type MinimalPackageJSON = { + name: string; + version: string; + dependencies?: Record; +} & Record; + +export interface Project { + cwd: string; + files: string[]; + packageJSON: MinimalPackageJSON; + strapiVersion: Version.SemVer; + + getFilesByExtensions(extensions: FileExtension[]): string[]; + runCodemods(codemods: Codemod.List, options: RunCodemodsOptions): Promise; + + refresh(): this; +} diff --git a/packages/utils/upgrade/src/modules/report/index.ts b/packages/utils/upgrade/src/modules/report/index.ts new file mode 100644 index 00000000000..3d9b2ea0a95 --- /dev/null +++ b/packages/utils/upgrade/src/modules/report/index.ts @@ -0,0 +1,3 @@ +export type * as Report from './types'; + +export { codemodReportFactory, reportFactory } from './report'; diff --git a/packages/utils/upgrade/src/modules/report/report.ts b/packages/utils/upgrade/src/modules/report/report.ts new file mode 100644 index 00000000000..10b7a2762e0 --- /dev/null +++ b/packages/utils/upgrade/src/modules/report/report.ts @@ -0,0 +1,10 @@ +import type { Codemod } from '../codemod'; + +import type { CodemodReport, Report } from './types'; + +export const codemodReportFactory = (codemod: Codemod.Codemod, report: Report): CodemodReport => ({ + codemod, + report, +}); + +export const reportFactory = (report: Report): Report => ({ ...report }); diff --git a/packages/utils/upgrade/src/modules/report/types.ts b/packages/utils/upgrade/src/modules/report/types.ts new file mode 100644 index 00000000000..aa4581404ca --- /dev/null +++ b/packages/utils/upgrade/src/modules/report/types.ts @@ -0,0 +1,17 @@ +import type { Codemod } from '../codemod'; + +export interface CodemodReport { + codemod: Codemod.Codemod; + report: Report; +} + +export type Collection = Report[]; + +export interface Report { + error: number; + ok: number; + nochange: number; + skip: number; + timeElapsed: string; + stats: Record; +} diff --git a/packages/utils/upgrade/src/modules/requirement/index.ts b/packages/utils/upgrade/src/modules/requirement/index.ts new file mode 100644 index 00000000000..9d4ac150978 --- /dev/null +++ b/packages/utils/upgrade/src/modules/requirement/index.ts @@ -0,0 +1,3 @@ +export type * as Requirement from './types'; + +export { requirementFactory } from './requirement'; diff --git a/packages/utils/upgrade/src/modules/requirement/requirement.ts b/packages/utils/upgrade/src/modules/requirement/requirement.ts new file mode 100644 index 00000000000..c673cf59a54 --- /dev/null +++ b/packages/utils/upgrade/src/modules/requirement/requirement.ts @@ -0,0 +1,76 @@ +import type { + Requirement as RequirementInterface, + RequirementTestCallback, + TestContext, + TestResult, +} from './types'; + +export class Requirement implements RequirementInterface { + readonly isRequired: boolean; + + readonly name: string; + + readonly testCallback: RequirementTestCallback | null; + + children: RequirementInterface[]; + + constructor(name: string, testCallback: RequirementTestCallback | null, isRequired?: boolean) { + this.name = name; + this.testCallback = testCallback; + this.isRequired = isRequired ?? true; + this.children = []; + } + + setChildren(children: RequirementInterface[]) { + this.children = children; + return this; + } + + addChild(child: RequirementInterface) { + this.children.push(child); + return this; + } + + asOptional() { + const newInstance = requirementFactory(this.name, this.testCallback, false); + + newInstance.setChildren(this.children); + + return newInstance; + } + + asRequired() { + const newInstance = requirementFactory(this.name, this.testCallback, true); + + newInstance.setChildren(this.children); + + return newInstance; + } + + async test(context: TestContext) { + try { + await this.testCallback?.(context); + return ok(); + } catch (e) { + if (e instanceof Error) { + return errored(e); + } + + if (typeof e === 'string') { + return errored(new Error(e)); + } + + return errored(new Error('Unknown error')); + } + } +} + +const ok = (): TestResult => ({ pass: true, error: null }); + +const errored = (error: Error): TestResult => ({ pass: false, error }); + +export const requirementFactory = ( + name: string, + testCallback: RequirementTestCallback | null, + isRequired?: boolean +) => new Requirement(name, testCallback, isRequired); diff --git a/packages/utils/upgrade/src/modules/requirement/types.ts b/packages/utils/upgrade/src/modules/requirement/types.ts new file mode 100644 index 00000000000..2e6fd8bb0ad --- /dev/null +++ b/packages/utils/upgrade/src/modules/requirement/types.ts @@ -0,0 +1,53 @@ +import type { Project } from '../project'; +import type { MaybePromise } from '../../types'; +import type { Version } from '../version'; +import type { NPMPackageVersion } from '../npm/types'; + +export type TestResult = { pass: true; error: null } | { pass: false; error: Error }; + +export interface Requirement { + name: string; + isRequired: boolean; + testCallback: RequirementTestCallback | null; + children: Requirement[]; + + setChildren(children: Requirement[]): this; + addChild(child: Requirement): this; + + asRequired(): Requirement; + asOptional(): Requirement; + + test(context: TestContext): Promise; +} + +export interface TestContext { + target: Version.SemVer; + npmVersionsMatches: NPMPackageVersion[]; + project: Project; +} + +export type RequirementTestCallback = (context: TestContext) => MaybePromise; + +export interface RequirementInformation { + name: string; + isRequired: boolean; + position: number; + remaining: number; + total: number; +} + +export interface ChainEvents { + start: (information: RequirementInformation) => MaybePromise; + success: (information: RequirementInformation) => MaybePromise; + failure: (information: RequirementInformation, error: Error) => MaybePromise; +} + +export type ChainEventKind = keyof ChainEvents; + +export interface Chain { + requirements: Requirement[]; + + on(event: TEventKind, callback: ChainEvents[TEventKind]): void; + + test(): MaybePromise; +} diff --git a/packages/utils/upgrade/src/modules/runner/__tests__/code.test.ts b/packages/utils/upgrade/src/modules/runner/__tests__/code.test.ts new file mode 100644 index 00000000000..2e1eb3f6ee5 --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/__tests__/code.test.ts @@ -0,0 +1,91 @@ +/* eslint-disable import/first */ + +import path from 'node:path'; +import { vol, fs } from 'memfs'; + +jest.mock('fs', () => fs); +jest.mock('jscodeshift/src/Runner', () => ({ run: jest.fn() })); + +import { run as jscodeshift } from 'jscodeshift/src/Runner'; + +import { codeRunnerFactory } from '../code'; +import { codemodFactory } from '../../codemod'; +import { semVerFactory } from '../../version'; + +import type { CodeRunnerConfiguration } from '../code'; + +const cwd = '/__tests__'; + +const jsonCodemod = codemodFactory({ + kind: 'json', + baseDirectory: cwd, + filename: 'foo.json', + version: semVerFactory('1.2.3'), +}); + +const codeCodemod = codemodFactory({ + kind: 'code', + baseDirectory: cwd, + filename: 'foo.ts', + version: semVerFactory('1.2.3'), +}); + +const files = { + 'a.js': 'a.js', + 'b.js': 'b.js', + 'c.ts': 'c.ts', +} as const; + +const paths = Object.keys(files).map((filename) => path.join(cwd, filename)); + +const configuration: CodeRunnerConfiguration = { + dry: true, + print: false, + silent: true, + extensions: 'js,ts', + runInBand: true, + verbose: 0, + babel: true, +}; + +describe('Runner (code)', () => { + const codeRunner = codeRunnerFactory(paths, configuration); + + beforeEach(() => { + vol.reset(); + vol.fromJSON(files, cwd); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + describe('valid()', () => { + test('Returns true for "code" codemods', () => { + const isValid = codeRunner.valid(codeCodemod); + + expect(isValid).toBe(true); + }); + + test('Returns false for "json" codemods"', () => { + const isValid = codeRunner.valid(jsonCodemod); + + expect(isValid).toBe(false); + }); + }); + + describe('run()', () => { + test('Delegate execution to jscodeshift for valid codemods', async () => { + await codeRunner.run(codeCodemod); + + expect(jscodeshift).toHaveBeenCalledWith(codeCodemod.path, paths, configuration); + }); + + test('Throw on invalid codemod', async () => { + await expect(codeRunner.run(jsonCodemod)).rejects.toThrow( + `Invalid codemod provided to the runner: ${jsonCodemod.filename}` + ); + }); + }); +}); diff --git a/packages/utils/upgrade/src/modules/runner/__tests__/json-transform-api.test.ts b/packages/utils/upgrade/src/modules/runner/__tests__/json-transform-api.test.ts new file mode 100644 index 00000000000..92650f3b116 --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/__tests__/json-transform-api.test.ts @@ -0,0 +1,99 @@ +import { cloneDeep } from 'lodash/fp'; + +import type { Utils } from '@strapi/types'; + +import { createJSONTransformAPI } from '../json/transform-api'; + +import type { JSONTransformAPI } from '../json'; + +const model = { foo: 'bar', nested: { bar: 42 } } as const; + +describe('JSON Transform API', () => { + let api: JSONTransformAPI; + let obj: Utils.JSONObject; + + beforeEach(() => { + obj = cloneDeep(model); + api = createJSONTransformAPI(obj); + }); + + test('Modifications made on the base object are ignored', () => { + Object.assign(obj, { another: 'property ' }); + + expect(api.root()).toStrictEqual(model); + }); + + describe('Get', () => { + test('Calling get with a non-existent property returns undefined', () => { + const value = api.get('unknown-path'); + + expect(value).toBeUndefined(); + }); + + test('Calling get with a non-existent property and a default value returns the default value', () => { + const value = api.get('unknown-path', 42); + + expect(value).toBe(42); + }); + + test('Calling get on an existing property returns the actual value', () => { + const value = api.get('foo'); + + expect(value).toBe('bar'); + }); + + test('Calling get without any path returns the whole object', () => { + const value = api.get(); + + expect(value).toStrictEqual(model); + }); + }); + + describe('Has', () => { + test('Calling has with a non-existent property returns false', () => { + const exists = api.has('unknown-path'); + + expect(exists).toBe(false); + }); + + test('Calling has with a valid property returns true', () => { + const exists = api.has('foo'); + + expect(exists).toBe(true); + }); + }); + + describe('Set', () => { + test('Calling set on an already existing property overrides its value', () => { + api.set('foo', 'baz'); + + expect(api.root()).toStrictEqual({ ...model, foo: 'baz' }); + }); + + test('Calling set on a non-existent property creates it', () => { + api.set('bar', 'baz'); + + expect(api.root()).toStrictEqual({ ...model, bar: 'baz' }); + }); + + test('Calling set on a nested property updates it', () => { + api.set('nested.newProp', 1); + + expect(api.root()).toStrictEqual({ ...model, nested: { ...model.nested, newProp: 1 } }); + }); + }); + + describe('Merge', () => { + test('Calling merge with conflicting properties operates a deep merge', () => { + const other = { baz: 'foo', nested: { newProp: 84 } }; + + api.merge(other); + + expect(api.root()).toStrictEqual({ + foo: 'bar', + baz: 'foo', + nested: { bar: 42, newProp: 84 }, + }); + }); + }); +}); diff --git a/packages/utils/upgrade/src/modules/runner/__tests__/json-transform.test.ts b/packages/utils/upgrade/src/modules/runner/__tests__/json-transform.test.ts new file mode 100644 index 00000000000..005255115a6 --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/__tests__/json-transform.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable import/first */ + +import path from 'node:path'; +import { vol, fs } from 'memfs'; + +jest.mock('fs', () => fs); +jest.mock('esbuild-register/dist/node', () => ({ + register: jest.fn(() => ({ unregister: jest.fn() })), +})); + +const cwd = '/__tests__'; + +const files = { + 'a.json': '{ "foo": "bar", "nested": { "bar": 42 } }', + 'b.json': '{ "foo": "baz", "nb": 42 }', +} as const; + +const paths = Object.keys(files).map((f) => path.join(cwd, f)); + +const codemodNoReturnValue = { + path: 'no-return.json.ts', + handler: jest.fn() satisfies JSONTransform, +}; + +const codemodNoUpdate = { + path: 'no-update.json.ts', + handler: jest.fn((file) => file.json) satisfies JSONTransform, +}; + +const codemodUpdate = { + path: 'update.json.ts', + handler: jest.fn(() => ({ unknown: 'object' })) satisfies JSONTransform, +}; + +const codemodThrow = { + path: 'throw.json.ts', + handler: jest.fn(() => { + throw new Error(); + }) satisfies JSONTransform, +}; + +const codemodFullPath = (codemodPath: string) => path.join(cwd, codemodPath); + +const allCodemods = [codemodNoReturnValue, codemodNoUpdate, codemodUpdate, codemodThrow]; + +for (const codemod of allCodemods) { + jest.mock(codemodFullPath(codemod.path), () => codemod.handler, { virtual: true }); +} + +for (const filepath of paths) { + jest.mock(filepath, () => files[path.basename(filepath)], { virtual: true }); +} + +const configuration: JSONRunnerConfiguration = { dry: true, cwd }; + +import { transformJSON } from '../json/transform'; + +import type { JSONRunnerConfiguration, JSONTransform } from '../json'; + +describe('JSON Transform', () => { + beforeEach(() => { + vol.reset(); + vol.fromJSON(files, cwd); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Codemods that return nothing are considered as "errored" state', async () => { + const codemodPath = codemodFullPath(codemodNoReturnValue.path); + + const report = await transformJSON(codemodPath, paths, configuration); + + expect(report.ok).toBe(0); + expect(report.nochange).toBe(0); + expect(report.skip).toBe(0); + expect(report.error).toBe(2); + }); + + test('Codemods that throw an error are considered as "errored" state', async () => { + const codemodPath = codemodFullPath(codemodThrow.path); + + const report = await transformJSON(codemodPath, paths, configuration); + + expect(report.ok).toBe(0); + expect(report.nochange).toBe(0); + expect(report.skip).toBe(0); + expect(report.error).toBe(2); + }); + + test('Leaving the JSON object as-is is considered as "unchanged" state', async () => { + const codemodPath = codemodFullPath(codemodNoUpdate.path); + + const report = await transformJSON(codemodPath, paths, configuration); + + expect(report.ok).toBe(0); + expect(report.nochange).toBe(2); + expect(report.skip).toBe(0); + expect(report.error).toBe(0); + }); + + test('Making updates to the JSON object is considered as "ok" state', async () => { + const codemodPath = codemodFullPath(codemodUpdate.path); + + const report = await transformJSON(codemodPath, paths, configuration); + + expect(report.ok).toBe(2); + expect(report.nochange).toBe(0); + expect(report.skip).toBe(0); + expect(report.error).toBe(0); + }); + + test('Making updates in non-dry mode changes the file content on the disk', async () => { + const codemodPath = codemodFullPath(codemodUpdate.path); + const expected = { unknown: 'object' }; + + await transformJSON(codemodPath, paths, { ...configuration, dry: false }); + + paths.forEach((filepath) => { + const fileContent = JSON.parse(vol.readFileSync(filepath).toString()); + + expect(fileContent).toStrictEqual(expected); + }); + }); +}); diff --git a/packages/utils/upgrade/src/modules/runner/__tests__/json.test.ts b/packages/utils/upgrade/src/modules/runner/__tests__/json.test.ts new file mode 100644 index 00000000000..246a5443f53 --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/__tests__/json.test.ts @@ -0,0 +1,87 @@ +/* eslint-disable import/first */ + +import path from 'node:path'; +import { vol, fs } from 'memfs'; + +jest.mock('fs', () => fs); +jest.mock('../json/transform', () => ({ transformJSON: jest.fn() })); + +import { jsonRunnerFactory } from '../json'; +import { transformJSON } from '../json/transform'; +import { codemodFactory } from '../../codemod'; +import { semVerFactory } from '../../version'; + +import type { JSONRunnerConfiguration } from '../json'; + +const cwd = '/__tests__'; + +const jsonCodemod = codemodFactory({ + kind: 'json', + baseDirectory: cwd, + filename: 'foo.json', + version: semVerFactory('1.2.3'), +}); + +const codeCodemod = codemodFactory({ + kind: 'code', + baseDirectory: cwd, + filename: 'foo.ts', + version: semVerFactory('1.2.3'), +}); + +const files = { + 'a.json': 'a.json', + 'b.json': 'b.json', + 'c.json': 'c.json', +} as const; + +const paths = Object.keys(files).map((filename) => path.join(cwd, filename)); + +const configuration: JSONRunnerConfiguration = { + dry: true, + cwd, +}; + +describe('Runner (json)', () => { + const jsonRunner = jsonRunnerFactory(paths, configuration); + + beforeEach(() => { + vol.reset(); + vol.fromJSON(files, cwd); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + describe('valid()', () => { + test('Returns true for "json" codemods', () => { + const isValid = jsonRunner.valid(jsonCodemod); + + expect(isValid).toBe(true); + }); + + test('Returns false for "code" codemods"', () => { + const isValid = jsonRunner.valid(codeCodemod); + + expect(isValid).toBe(false); + }); + }); + + describe('run()', () => { + test('Delegate execution to the JSON runner for valid codemods', async () => { + await jsonRunner.run(jsonCodemod); + + expect(transformJSON).toHaveBeenCalledWith(jsonCodemod.path, paths, configuration); + }); + + test('Throw on invalid codemod', async () => { + const codemod = codeCodemod; + + await expect(jsonRunner.run(codemod)).rejects.toThrow( + `Invalid codemod provided to the runner: ${codemod.filename}` + ); + }); + }); +}); diff --git a/packages/utils/upgrade/src/modules/runner/code/code.ts b/packages/utils/upgrade/src/modules/runner/code/code.ts new file mode 100644 index 00000000000..d80bfd9669a --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/code/code.ts @@ -0,0 +1,18 @@ +import { run as jscodeshift } from 'jscodeshift/src/Runner'; + +import { AbstractRunner } from '../runner'; + +import type { Codemod } from '../../codemod'; +import type { CodeRunnerConfiguration } from './types'; + +export class CodeRunner extends AbstractRunner { + runner = jscodeshift; + + valid(codemod: Codemod.Codemod): boolean { + return codemod.kind === 'code'; + } +} + +export const codeRunnerFactory = (paths: string[], configuration: CodeRunnerConfiguration) => { + return new CodeRunner(paths, configuration); +}; diff --git a/packages/utils/upgrade/src/modules/runner/code/index.ts b/packages/utils/upgrade/src/modules/runner/code/index.ts new file mode 100644 index 00000000000..d3acac9957d --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/code/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; + +export { codeRunnerFactory } from './code'; diff --git a/packages/utils/upgrade/src/modules/runner/code/types.ts b/packages/utils/upgrade/src/modules/runner/code/types.ts new file mode 100644 index 00000000000..8de6dba56fc --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/code/types.ts @@ -0,0 +1,10 @@ +export interface CodeRunnerConfiguration { + dry?: boolean; + extensions?: string; + runInBand?: boolean; + verbose?: number; + babel?: boolean; + print?: boolean; + silent?: boolean; + parser?: 'js' | 'ts'; +} diff --git a/packages/utils/upgrade/src/modules/runner/index.ts b/packages/utils/upgrade/src/modules/runner/index.ts new file mode 100644 index 00000000000..03c39abc2b7 --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/index.ts @@ -0,0 +1,4 @@ +export type * from './types'; + +export * as json from './json'; +export * as code from './code'; diff --git a/packages/utils/upgrade/src/modules/runner/json/index.ts b/packages/utils/upgrade/src/modules/runner/json/index.ts new file mode 100644 index 00000000000..a78242200cf --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/json/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; + +export { jsonRunnerFactory } from './json'; diff --git a/packages/utils/upgrade/src/modules/runner/json/json.ts b/packages/utils/upgrade/src/modules/runner/json/json.ts new file mode 100644 index 00000000000..70c6dafebbf --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/json/json.ts @@ -0,0 +1,18 @@ +import { AbstractRunner } from '../runner'; + +import { transformJSON } from './transform'; + +import type { Codemod } from '../../codemod'; +import type { JSONRunnerConfiguration } from './types'; + +export class JSONRunner extends AbstractRunner { + runner = transformJSON; + + valid(codemod: Codemod.Codemod): boolean { + return codemod.kind === 'json'; + } +} + +export const jsonRunnerFactory = (paths: string[], configuration: JSONRunnerConfiguration) => { + return new JSONRunner(paths, configuration); +}; diff --git a/packages/utils/upgrade/src/modules/runner/json/transform-api.ts b/packages/utils/upgrade/src/modules/runner/json/transform-api.ts new file mode 100644 index 00000000000..d510f247b72 --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/json/transform-api.ts @@ -0,0 +1,41 @@ +import { cloneDeep, get, has, set, merge } from 'lodash/fp'; + +import type { JSONObject, JSONTransformAPI as JSONTransformAPIInterface, JSONValue } from './types'; + +export class JSONTransformAPI implements JSONTransformAPIInterface { + private json: JSONObject; + + constructor(json: JSONObject) { + this.json = cloneDeep(json); + } + + get(path?: string, defaultValue?: T): T | undefined { + if (!path) { + return this.root() as T; + } + + return cloneDeep(get(path, this.json) ?? defaultValue) as T; + } + + has(path: string) { + return has(path, this.json); + } + + merge(other: JSONObject) { + this.json = merge(other, this.json); + + return this; + } + + root(): JSONObject { + return cloneDeep(this.json); + } + + set(path: string, value: JSONValue) { + this.json = set(path, value, this.json); + + return this; + } +} + +export const createJSONTransformAPI = (object: JSONObject) => new JSONTransformAPI(object); diff --git a/packages/utils/upgrade/src/modules/runner/json/transform.ts b/packages/utils/upgrade/src/modules/runner/json/transform.ts new file mode 100644 index 00000000000..da98ae37287 --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/json/transform.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +import assert from 'node:assert'; +import fse from 'fs-extra'; +import { isEqual } from 'lodash/fp'; +import { register } from 'esbuild-register/dist/node'; + +import { createJSONTransformAPI } from './transform-api'; + +import type { Report } from '../../report'; + +import type { JSONRunnerConfiguration, JSONSourceFile, JSONTransformParams } from './types'; + +export const transformJSON = async ( + codemodPath: string, + paths: string[], + config: JSONRunnerConfiguration +): Promise => { + const { dry } = config; + const startTime = process.hrtime(); + + const report: Report.Report = { + ok: 0, + nochange: 0, + skip: 0, + error: 0, + timeElapsed: '', + stats: {}, + }; + + const esbuildOptions = { extensions: ['.js', '.mjs', '.ts'] }; + const { unregister } = register(esbuildOptions); + + const module = require(codemodPath); + + unregister(); + + const codemod = typeof module.default === 'function' ? module.default : module; + + assert(typeof codemod === 'function', `Codemod must be a function. Found ${typeof codemod}`); + + for (const path of paths) { + try { + const json = require(path); + // TODO: Optimize the API to limit parse/stringify operations + const file: JSONSourceFile = { path, json }; + const params: JSONTransformParams = { cwd: config.cwd, json: createJSONTransformAPI }; + + const out = await codemod(file, params); + + if (out === undefined) { + report.error += 1; + } + // If the json object has modifications + else if (!isEqual(json, out)) { + if (!dry) { + fse.writeFileSync(path, JSON.stringify(out, null, 2)); + } + report.ok += 1; + } + // No changes + else { + report.nochange += 1; + } + } catch { + report.error += 1; + } + } + + const endTime = process.hrtime(startTime); + report.timeElapsed = (endTime[0] + endTime[1] / 1e9).toFixed(3); + + return report; +}; diff --git a/packages/utils/upgrade/src/modules/runner/json/types.ts b/packages/utils/upgrade/src/modules/runner/json/types.ts new file mode 100644 index 00000000000..3b201a00f0c --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/json/types.ts @@ -0,0 +1,34 @@ +import type { Utils } from '@strapi/types'; + +export interface JSONRunnerConfiguration { + dry?: boolean; + cwd: string; +} + +export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; + +export type JSONArray = Array; + +export interface JSONObject { + [key: string]: JSONValue; +} + +export interface JSONSourceFile { + path: string; + json: Utils.JSONObject; +} + +export interface JSONTransformParams { + cwd: string; + json: (object: Utils.JSONObject) => JSONTransformAPI; +} + +export type JSONTransform = (file: JSONSourceFile, params: JSONTransformParams) => Utils.JSONObject; + +export interface JSONTransformAPI { + get(path?: string, defaultValue?: T): T | undefined; + has(path: string): boolean; + set(path: string, value: Utils.JSONValue): this; + merge(other: Utils.JSONObject): this; + root(): Utils.JSONObject; +} diff --git a/packages/utils/upgrade/src/modules/runner/runner.ts b/packages/utils/upgrade/src/modules/runner/runner.ts new file mode 100644 index 00000000000..9da8ed04cdc --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/runner.ts @@ -0,0 +1,32 @@ +import type { Codemod } from '../codemod'; + +import type { Runner as RunnerInterface, RunnerConfiguration, RunnerFunction } from './types'; + +export abstract class AbstractRunner + implements RunnerInterface +{ + abstract runner: RunnerFunction; + + paths: string[]; + + configuration: TConfig; + + constructor(paths: string[], configuration: TConfig) { + this.paths = paths; + this.configuration = configuration; + } + + async run(codemod: Codemod.Codemod, configuration?: TConfig) { + const isValidCodemod = this.valid(codemod); + + if (!isValidCodemod) { + throw new Error(`Invalid codemod provided to the runner: ${codemod.filename}`); + } + + const runConfiguration: TConfig = { ...this.configuration, ...configuration }; + + return this.runner(codemod.path, this.paths, runConfiguration); + } + + abstract valid(codemod: Codemod.Codemod): boolean; +} diff --git a/packages/utils/upgrade/src/modules/runner/types.ts b/packages/utils/upgrade/src/modules/runner/types.ts new file mode 100644 index 00000000000..0ebbb461f2f --- /dev/null +++ b/packages/utils/upgrade/src/modules/runner/types.ts @@ -0,0 +1,22 @@ +import type { Codemod } from '../codemod'; +import type { Report } from '../report'; + +export interface RunnerConfiguration { + dry?: boolean; +} + +export interface Runner { + runner: RunnerFunction; + paths: string[]; + configuration: TConfig; + + valid(codemod: Codemod.Codemod): boolean; + + run(codemod: Codemod.Codemod, configuration?: TConfig): Promise; +} + +export type RunnerFunction = ( + codemodPath: string, + paths: string[], + configuration: TConfig +) => Promise; diff --git a/packages/utils/upgrade/src/modules/timer/__tests__/timer.test.ts b/packages/utils/upgrade/src/modules/timer/__tests__/timer.test.ts new file mode 100644 index 00000000000..119baed6378 --- /dev/null +++ b/packages/utils/upgrade/src/modules/timer/__tests__/timer.test.ts @@ -0,0 +1,74 @@ +import { Timer, timerFactory } from '../timer'; + +describe('Timer', () => { + const FIXED_NOW = Date.now(); + + beforeEach(() => { + // Reset timers to a specific time before every test + jest.useFakeTimers({ now: FIXED_NOW }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('Can create a timer using the factory', () => { + const timer = timerFactory(); + + expect(timer).toBeInstanceOf(Timer); + }); + + test('The start time should be set to "now" by default', () => { + const timer = timerFactory(); + + expect(timer.start).toBe(FIXED_NOW); + }); + + test('The end time should be set to "null" by default', () => { + const timer = timerFactory(); + + expect(timer.end).toBeNull(); + }); + + test('The elapsed time (ms) should be 0 when created', () => { + const timer = timerFactory(); + + expect(timer.elapsedMs).toBe(0); + }); + + test('The elapsed time (ms) should be dynamic, depending on the current time', () => { + const elapsedTimeMs = 250; + const timer = timerFactory(); + + jest.advanceTimersByTime(elapsedTimeMs); + + expect(timer.elapsedMs).toBe(elapsedTimeMs); + }); + + test('Calling .stop() should freeze the timer components', () => { + const elapsedTimeMs = 42; + const timer = timerFactory(); + + jest.advanceTimersByTime(elapsedTimeMs); + + timer.stop(); + + expect(timer.start).toBe(FIXED_NOW); + expect(timer.end).toBe(FIXED_NOW + elapsedTimeMs); + expect(timer.elapsedMs).toBe(elapsedTimeMs); + }); + + test(`Calling .reset() should reinitialize the timer's components based on the current time`, () => { + const elapsedTimeMs = 42; + const timer = timerFactory(); + + jest.advanceTimersByTime(elapsedTimeMs); + + timer.stop(); + timer.reset(); + + expect(timer.start).toBe(FIXED_NOW + elapsedTimeMs); + expect(timer.end).toBe(null); + expect(timer.elapsedMs).toBe(0); + }); +}); diff --git a/packages/utils/upgrade/src/modules/timer/constants.ts b/packages/utils/upgrade/src/modules/timer/constants.ts new file mode 100644 index 00000000000..cef6da77c25 --- /dev/null +++ b/packages/utils/upgrade/src/modules/timer/constants.ts @@ -0,0 +1 @@ +export const ONE_SECOND_MS = 1000; diff --git a/packages/utils/upgrade/src/modules/timer/index.ts b/packages/utils/upgrade/src/modules/timer/index.ts new file mode 100644 index 00000000000..f4703b04081 --- /dev/null +++ b/packages/utils/upgrade/src/modules/timer/index.ts @@ -0,0 +1,4 @@ +export type * from './types'; + +export { timerFactory } from './timer'; +export * as constants from './constants'; diff --git a/packages/utils/upgrade/src/modules/timer/timer.ts b/packages/utils/upgrade/src/modules/timer/timer.ts new file mode 100644 index 00000000000..2c0428a6e46 --- /dev/null +++ b/packages/utils/upgrade/src/modules/timer/timer.ts @@ -0,0 +1,37 @@ +import type { Timer as TimerInterface, TimeInterval } from './types'; + +export class Timer implements TimerInterface { + private interval!: TimeInterval; + + constructor() { + this.reset(); + } + + get elapsedMs() { + const { start, end } = this.interval; + + return end ? end - start : Date.now() - start; + } + + get end() { + return this.interval.end; + } + + get start() { + return this.interval.start; + } + + stop() { + this.interval.end = Date.now(); + + return this.elapsedMs; + } + + reset() { + this.interval = { start: Date.now(), end: null }; + + return this; + } +} + +export const timerFactory = () => new Timer(); diff --git a/packages/utils/upgrade/src/modules/timer/types.ts b/packages/utils/upgrade/src/modules/timer/types.ts new file mode 100644 index 00000000000..bd3cda08d67 --- /dev/null +++ b/packages/utils/upgrade/src/modules/timer/types.ts @@ -0,0 +1,13 @@ +export interface Timer { + get start(): number; + get end(): number | null; + get elapsedMs(): number; + + stop(): number; + reset(): this; +} + +export interface TimeInterval { + start: number; + end: number | null; +} diff --git a/packages/utils/upgrade/src/modules/upgrader/constants.ts b/packages/utils/upgrade/src/modules/upgrader/constants.ts new file mode 100644 index 00000000000..34487aa9cab --- /dev/null +++ b/packages/utils/upgrade/src/modules/upgrader/constants.ts @@ -0,0 +1 @@ +export const STRAPI_PACKAGE_NAME = '@strapi/strapi'; diff --git a/packages/utils/upgrade/src/modules/upgrader/index.ts b/packages/utils/upgrade/src/modules/upgrader/index.ts new file mode 100644 index 00000000000..a40986ae6c0 --- /dev/null +++ b/packages/utils/upgrade/src/modules/upgrader/index.ts @@ -0,0 +1,4 @@ +export type * from './types'; + +export { upgraderFactory } from './upgrader'; +export * as constants from './constants'; diff --git a/packages/utils/upgrade/src/modules/upgrader/types.ts b/packages/utils/upgrade/src/modules/upgrader/types.ts new file mode 100644 index 00000000000..33f3fa0e33c --- /dev/null +++ b/packages/utils/upgrade/src/modules/upgrader/types.ts @@ -0,0 +1,26 @@ +import type { Version } from '../version'; +import type { Requirement } from '../requirement'; +import type { Logger } from '../logger'; +import type { MaybePromise } from '../../types'; + +export interface Upgrader { + setTarget(target: Version.ReleaseType | Version.SemVer): this; + setRequirements(requirements: Requirement.Requirement[]): this; + setLogger(logger: Logger): this; + + dry(enabled?: boolean): this; + onConfirm(callback: ConfirmationCallback | null): this; + + addRequirement(requirement: Requirement.Requirement): this; + + upgrade(): Promise; +} + +export type UpgradeReport = + | { + success: true; + error: null; + } + | { success: false; error: Error }; + +export type ConfirmationCallback = (message: string) => MaybePromise; diff --git a/packages/utils/upgrade/src/modules/upgrader/upgrader.ts b/packages/utils/upgrade/src/modules/upgrader/upgrader.ts new file mode 100644 index 00000000000..e5a062f7b0d --- /dev/null +++ b/packages/utils/upgrade/src/modules/upgrader/upgrader.ts @@ -0,0 +1,201 @@ +import assert from 'node:assert'; + +import { + codemodRepositoryFactory, + constants as codemodRepositoryConstants, +} from '../codemod-repository'; +import { isLiteralSemVer, isSemVer, rangeFromVersions, semVerFactory } from '../version'; +import { unknownToError } from '../error'; +import * as f from '../format'; + +import type { ConfirmationCallback, Upgrader as UpgraderInterface, UpgradeReport } from './types'; +import type { Version } from '../version'; +import type { Logger } from '../logger'; +import type { Requirement } from '../requirement'; +import type { NPM } from '../npm'; +import type { Project } from '../project'; + +export class Upgrader implements UpgraderInterface { + private project: Project; + + private npmPackage: NPM.Package; + + private target: Version.SemVer; + + private isDry: boolean; + + private logger: Logger | null; + + private requirements: Requirement.Requirement[]; + + private confirmationCallback: ConfirmationCallback | null; + + constructor(project: Project, target: Version.SemVer, npmPackage: NPM.Package) { + this.project = project; + this.target = target; + this.npmPackage = npmPackage; + + this.isDry = false; + + this.requirements = []; + + this.logger = null; + this.confirmationCallback = null; + } + + setRequirements(requirements: Requirement.Requirement[]) { + this.requirements = requirements; + return this; + } + + setTarget(target: Version.SemVer) { + this.target = target; + return this; + } + + setLogger(logger: Logger) { + this.logger = logger; + return this; + } + + onConfirm(callback: ConfirmationCallback | null) { + this.confirmationCallback = callback; + return this; + } + + dry(enabled: boolean = true) { + this.isDry = enabled; + return this; + } + + addRequirement(requirement: Requirement.Requirement) { + this.requirements.push(requirement); + return this; + } + + async upgrade(): Promise { + const range = rangeFromVersions(this.project.strapiVersion, this.target); + const npmVersionsMatches = this.npmPackage?.findVersionsInRange(range) ?? []; + + try { + await this.checkRequirements(this.requirements, { + npmVersionsMatches, + project: this.project, + target: this.target, + }); + + // todo: upgrade package json + // todo: install dependencies + + await this.runCodemods(range); + } catch (e) { + return erroredReport(unknownToError(e)); + } + + return successReport(); + } + + private async checkRequirements( + requirements: Requirement.Requirement[], + context: Requirement.TestContext + ) { + for (const requirement of requirements) { + const { pass, error } = await requirement.test(context); + + if (pass) { + await this.onSuccessfulRequirement(requirement, context); + } else { + await this.onFailedRequirement(requirement, error); + } + } + } + + private async onSuccessfulRequirement( + requirement: Requirement.Requirement, + context: Requirement.TestContext + ): Promise { + const hasChildren = requirement.children.length > 0; + + if (hasChildren) { + await this.checkRequirements(requirement.children, context); + } + } + + private async onFailedRequirement( + requirement: Requirement.Requirement, + originalError: Error + ): Promise { + const errorMessage = `Upgrade requirement "${requirement.name}" failed: ${originalError.message}`; + const confirmationMessage = `Optional requirement "${requirement.name}" failed with "${originalError.message}", do you want to proceed anyway?`; + + const error = new Error(errorMessage); + + if (requirement.isRequired) { + throw error; + } + + const response = await this.confirmationCallback?.(confirmationMessage); + + if (!response) { + throw error; + } + + this.logger?.warn(errorMessage); + } + + private async runCodemods(range: Version.Range) { + const repository = codemodRepositoryFactory( + codemodRepositoryConstants.INTERNAL_CODEMODS_DIRECTORY + ); + + // Make sure we have access to the latest snapshots of codemods on the system + repository.refresh(); + + const versionedCodemods = repository.findByRange(range); + + const hasCodemodsToRun = versionedCodemods.length > 0; + if (!hasCodemodsToRun) { + this.logger?.debug(`Found no codemods to run for ${this.target}`); + return; + } + + // Flatten the collection to a single list of codemods, the original list should already be sorted + const codemods = versionedCodemods.map(({ codemods }) => codemods).flat(); + + const reports = await this.project.runCodemods(codemods, { dry: this.isDry }); + + this.logger?.raw(f.reports(reports)); + } +} + +export const upgraderFactory = ( + project: Project, + target: Version.ReleaseType | Version.SemVer, + npmPackage: NPM.Package +) => { + const range = rangeFromVersions(project.strapiVersion, target); + const npmVersionsMatches = npmPackage.findVersionsInRange(range); + + // The targeted version is the latest one that matches the given range + const targetedNPMVersion = npmVersionsMatches.at(-1); + + assert(targetedNPMVersion, `No available version found for ${range}`); + + // Make sure the latest version matched in the range is the same as the targeted one (only if target is a semver) + if (isSemVer(target) && target.raw !== targetedNPMVersion.version) { + throw new Error( + `${target} doesn't exist on the registry. Closest one found is ${targetedNPMVersion.version}` + ); + } + + if (!isLiteralSemVer(targetedNPMVersion.version)) { + throw new Error('Something wrong happened with the target version (not a literal semver)'); + } + + const semverTarget = semVerFactory(targetedNPMVersion.version); + + return new Upgrader(project, semverTarget, npmPackage); +}; + +const successReport = (): UpgradeReport => ({ success: true, error: null }); +const erroredReport = (error: Error): UpgradeReport => ({ success: false, error }); diff --git a/packages/utils/upgrade/src/modules/version/index.ts b/packages/utils/upgrade/src/modules/version/index.ts new file mode 100644 index 00000000000..fe9c1523b30 --- /dev/null +++ b/packages/utils/upgrade/src/modules/version/index.ts @@ -0,0 +1,6 @@ +export * from './semver'; +export * from './range'; + +// Since we're exporting an enum, we need to be able to access both its +// type & value, hence why we're not doing an export type * here +export * as Version from './types'; diff --git a/packages/utils/upgrade/src/modules/version/range.ts b/packages/utils/upgrade/src/modules/version/range.ts new file mode 100644 index 00000000000..e97b90bdb23 --- /dev/null +++ b/packages/utils/upgrade/src/modules/version/range.ts @@ -0,0 +1,41 @@ +import semver from 'semver'; + +import * as Version from './types'; +import { isSemVer, isSemVerReleaseType } from './semver'; + +export const rangeFactory = (range: string): Version.Range => { + return new semver.Range(range); +}; + +export const rangeFromReleaseType = (current: Version.SemVer, identifier: Version.ReleaseType) => { + const fromCurrentTo = (version: Version.LiteralVersion) => { + return rangeFactory(`>${current.raw} <=${version}`); + }; + + switch (identifier) { + case Version.ReleaseType.Major: { + // semver.inc(_, 'major') will target .0.0 which is what we want + // e.g. for 4.15.4, it'll return 5.0.0 + const nextMajor = semver.inc(current, 'major') as Version.LiteralSemVer; + return fromCurrentTo(nextMajor); + } + default: { + throw new Error('Not implemented'); + } + } +}; + +export const rangeFromVersions = ( + currentVersion: Version.SemVer, + target: Version.ReleaseType | Version.SemVer +) => { + if (isSemVer(target)) { + return rangeFactory(`>${currentVersion.raw} <=${target.raw}`); + } + + if (isSemVerReleaseType(target)) { + return rangeFromReleaseType(currentVersion, target); + } + + throw new Error(`Invalid target set: ${target}`); // TODO: better errors +}; diff --git a/packages/utils/upgrade/src/modules/version/semver.ts b/packages/utils/upgrade/src/modules/version/semver.ts new file mode 100644 index 00000000000..cca378edf74 --- /dev/null +++ b/packages/utils/upgrade/src/modules/version/semver.ts @@ -0,0 +1,22 @@ +import semver from 'semver'; + +import * as Version from './types'; + +export const semVerFactory = (version: Version.LiteralSemVer): Version.SemVer => { + return new semver.SemVer(version); +}; + +export const isLiteralSemVer = (str: string): str is Version.LiteralSemVer => { + const tokens = str.split('.'); + + return ( + tokens.length === 3 && + tokens.every((token) => !Number.isNaN(+token) && Number.isInteger(+token)) + ); +}; + +export const isSemVer = (value: unknown): value is semver.SemVer => value instanceof semver.SemVer; + +export const isSemVerReleaseType = (str: string): str is Version.ReleaseType => { + return Object.values(Version.ReleaseType).includes(str as Version.ReleaseType); +}; diff --git a/packages/utils/upgrade/src/modules/version/types.ts b/packages/utils/upgrade/src/modules/version/types.ts new file mode 100644 index 00000000000..82fb9e48f84 --- /dev/null +++ b/packages/utils/upgrade/src/modules/version/types.ts @@ -0,0 +1,16 @@ +export type Version = number; + +export type LiteralVersion = + | `${Version}` + | `${Version}.${Version}` + | `${Version}.${Version}.${Version}`; + +export type LiteralSemVer = `${Version}.${Version}.${Version}`; + +export type { SemVer, Range } from 'semver'; + +export enum ReleaseType { + Major = 'major', + Minor = 'minor', + Patch = 'patch', +} diff --git a/packages/utils/upgrade/src/tasks/index.ts b/packages/utils/upgrade/src/tasks/index.ts new file mode 100644 index 00000000000..d2e8cf5aae4 --- /dev/null +++ b/packages/utils/upgrade/src/tasks/index.ts @@ -0,0 +1,3 @@ +export type { Upgrade } from './upgrade'; + +export { upgrade } from './upgrade'; diff --git a/packages/utils/upgrade/src/tasks/upgrade/index.ts b/packages/utils/upgrade/src/tasks/upgrade/index.ts new file mode 100644 index 00000000000..9d10fee92cd --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/index.ts @@ -0,0 +1,4 @@ +export type * as Upgrade from './types'; + +export * from './upgrade'; +export * as requirements from './requirements'; diff --git a/packages/utils/upgrade/src/tasks/upgrade/requirements/common.ts b/packages/utils/upgrade/src/tasks/upgrade/requirements/common.ts new file mode 100644 index 00000000000..65dce216794 --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/requirements/common.ts @@ -0,0 +1,48 @@ +import simpleGit from 'simple-git'; + +import { requirementFactory } from '../../../modules/requirement'; + +export const REQUIRE_GIT_CLEAN_REPOSITORY = requirementFactory( + 'REQUIRE_GIT_CLEAN_REPOSITORY', + async (context) => { + const git = simpleGit({ baseDir: context.project.cwd }); + + const status = await git.status(); + + if (!status.isClean()) { + throw new Error( + 'Repository is not clean. Please commit or stash any changes before upgrading' + ); + } + } +); + +export const REQUIRE_GIT_REPOSITORY = requirementFactory( + 'REQUIRE_GIT_REPOSITORY', + async (context) => { + const git = simpleGit({ baseDir: context.project.cwd }); + + const isRepo = await git.checkIsRepo(); + + if (!isRepo) { + throw new Error('Not a git repository (or any of the parent directories)'); + } + } +).addChild(REQUIRE_GIT_CLEAN_REPOSITORY.asOptional()); + +export const REQUIRE_GIT_INSTALLED = requirementFactory( + 'REQUIRE_GIT_INSTALLED', + async (context) => { + const git = simpleGit({ baseDir: context.project.cwd }); + + try { + await git.version(); + } catch { + throw new Error('Git is not installed'); + } + } +).addChild(REQUIRE_GIT_REPOSITORY.asOptional()); + +export const REQUIRE_GIT = requirementFactory('REQUIRE_GIT', null).addChild( + REQUIRE_GIT_INSTALLED.asOptional() +); diff --git a/packages/utils/upgrade/src/tasks/upgrade/requirements/index.ts b/packages/utils/upgrade/src/tasks/upgrade/requirements/index.ts new file mode 100644 index 00000000000..7cdce9d3b55 --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/requirements/index.ts @@ -0,0 +1,2 @@ +export * as major from './major'; +export * as common from './common'; diff --git a/packages/utils/upgrade/src/tasks/upgrade/requirements/major.ts b/packages/utils/upgrade/src/tasks/upgrade/requirements/major.ts new file mode 100644 index 00000000000..8b379ae03c3 --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/requirements/major.ts @@ -0,0 +1,33 @@ +import { requirementFactory } from '../../../modules/requirement'; + +export const REQUIRE_AVAILABLE_NEXT_MAJOR = requirementFactory( + 'REQUIRE_AVAILABLE_NEXT_MAJOR', + (context) => { + const { project, target } = context; + + const currentMajor = project.strapiVersion.major; + const targetedMajor = target.major; + + if (targetedMajor === currentMajor) { + throw new Error(`You're already on the latest major version (v${currentMajor})`); + } + } +); + +export const REQUIRE_LATEST_FOR_CURRENT_MAJOR = requirementFactory( + 'REQUIRE_LATEST_FOR_CURRENT_MAJOR', + (context) => { + const { project, target, npmVersionsMatches } = context; + + if (npmVersionsMatches.length !== 1) { + const invalidVersions = npmVersionsMatches.slice(0, -1); + const invalidVersionsAsSemVer = invalidVersions.map((v) => v.version); + const nbInvalidVersions = npmVersionsMatches.length; + const currentMajor = project.strapiVersion.major; + + throw new Error( + `Doing a major upgrade requires to be on the latest v${currentMajor} version, but found ${nbInvalidVersions} versions between the current one and ${target}: ${invalidVersionsAsSemVer}` + ); + } + } +); diff --git a/packages/utils/upgrade/src/tasks/upgrade/types.ts b/packages/utils/upgrade/src/tasks/upgrade/types.ts new file mode 100644 index 00000000000..f2266a7754d --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/types.ts @@ -0,0 +1,12 @@ +import type { Version } from '../../modules/version'; + +import type { Logger } from '../../modules/logger'; +import type { ConfirmationCallback } from '../../modules/upgrader'; + +export interface UpgradeOptions { + logger: Logger; + confirm?: ConfirmationCallback; + cwd?: string; + dry?: boolean; + target: Version.ReleaseType | Version.SemVer; +} diff --git a/packages/utils/upgrade/src/tasks/upgrade/upgrade.ts b/packages/utils/upgrade/src/tasks/upgrade/upgrade.ts new file mode 100644 index 00000000000..723a4367345 --- /dev/null +++ b/packages/utils/upgrade/src/tasks/upgrade/upgrade.ts @@ -0,0 +1,46 @@ +import path from 'node:path'; + +import * as requirements from './requirements'; +import { timerFactory } from '../../modules/timer'; +import { upgraderFactory, constants as upgraderConstants } from '../../modules/upgrader'; +import { npmPackageFactory } from '../../modules/npm'; +import { projectFactory } from '../../modules/project'; +import { Version } from '../../modules/version'; + +import type { UpgradeOptions } from './types'; + +export const upgrade = async (options: UpgradeOptions) => { + const timer = timerFactory(); + const { logger } = options; + + // Make sure we're resolving the correct working directory based on the given input + const cwd = path.resolve(options.cwd ?? process.cwd()); + + const project = projectFactory(cwd); + const npmPackage = npmPackageFactory(upgraderConstants.STRAPI_PACKAGE_NAME); + // Load all versions from the registry + await npmPackage.refresh(); + + const upgrader = upgraderFactory(project, options.target, npmPackage) + .dry(options.dry ?? false) + .onConfirm(options.confirm ?? null) + .setLogger(logger); + + if (options.target === Version.ReleaseType.Major) { + upgrader + .addRequirement(requirements.major.REQUIRE_AVAILABLE_NEXT_MAJOR.asOptional()) + .addRequirement(requirements.major.REQUIRE_LATEST_FOR_CURRENT_MAJOR.asOptional()); + } + + upgrader.addRequirement(requirements.common.REQUIRE_GIT.asOptional()); + + const upgradeReport = await upgrader.upgrade(); + + if (!upgradeReport.success) { + throw upgradeReport.error; + } + + timer.stop(); + + logger.info(`Completed in ${timer.elapsedMs}`); +}; diff --git a/packages/utils/upgrade/src/types.ts b/packages/utils/upgrade/src/types.ts new file mode 100644 index 00000000000..ed2fbb0c3a7 --- /dev/null +++ b/packages/utils/upgrade/src/types.ts @@ -0,0 +1,7 @@ +import type { Logger } from './modules/logger'; + +export type MaybePromise = Promise | T; + +export interface ContextWithLogger { + logger: Logger; +} diff --git a/packages/utils/upgrade/tsconfig.build.json b/packages/utils/upgrade/tsconfig.build.json new file mode 100644 index 00000000000..5b708a803c9 --- /dev/null +++ b/packages/utils/upgrade/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "sourceMap": true + }, + "include": ["src"], + "exclude": ["**/__tests__/**", "**/cli/**", "./src/types.ts", "dist"] +} diff --git a/packages/utils/upgrade/tsconfig.eslint.json b/packages/utils/upgrade/tsconfig.eslint.json new file mode 100644 index 00000000000..1518b194aa5 --- /dev/null +++ b/packages/utils/upgrade/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src", "resources", "*.config.ts", "*.config.js", ".eslintrc.js"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/utils/upgrade/tsconfig.json b/packages/utils/upgrade/tsconfig.json new file mode 100644 index 00000000000..cb0daf702c5 --- /dev/null +++ b/packages/utils/upgrade/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "declarationMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "**/__tests__/**"] +} diff --git a/yarn.lock b/yarn.lock index fc92a97c3c8..2b47ab1c585 100644 --- a/yarn.lock +++ b/yarn.lock @@ -969,6 +969,16 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/code-frame@npm:7.23.5" + dependencies: + "@babel/highlight": "npm:^7.23.4" + chalk: "npm:^2.4.2" + checksum: 44e58529c9d93083288dc9e649c553c5ba997475a7b0758cc3ddc4d77b8a7d985dbe78cc39c9bbc61f26d50af6da1ddf0a3427eae8cc222a9370619b671ed8f5 + languageName: node + linkType: hard + "@babel/compat-data@npm:^7.20.5": version: 7.20.14 resolution: "@babel/compat-data@npm:7.20.14" @@ -1075,6 +1085,29 @@ __metadata: languageName: node linkType: hard +"@babel/core@npm:^7.23.0": + version: 7.23.5 + resolution: "@babel/core@npm:7.23.5" + dependencies: + "@ampproject/remapping": "npm:^2.2.0" + "@babel/code-frame": "npm:^7.23.5" + "@babel/generator": "npm:^7.23.5" + "@babel/helper-compilation-targets": "npm:^7.22.15" + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helpers": "npm:^7.23.5" + "@babel/parser": "npm:^7.23.5" + "@babel/template": "npm:^7.22.15" + "@babel/traverse": "npm:^7.23.5" + "@babel/types": "npm:^7.23.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: f24265172610dbffe0e315b6a8e8f87cf87d2643c8915196adcddd81c66a8eaeb1b36fea851e2308961636a180089a5f10becaa340d5b707d5f64e2e5ffb2bc8 + languageName: node + linkType: hard + "@babel/eslint-parser@npm:^7.19.1": version: 7.19.1 resolution: "@babel/eslint-parser@npm:7.19.1" @@ -1159,6 +1192,18 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/generator@npm:7.23.5" + dependencies: + "@babel/types": "npm:^7.23.5" + "@jridgewell/gen-mapping": "npm:^0.3.2" + "@jridgewell/trace-mapping": "npm:^0.3.17" + jsesc: "npm:^2.5.1" + checksum: 094af79c2e8fdb0cfd06b42ff6a39a8a95639bc987cace44f52ed5c46127f5469eb20ab5f4c8991fc00fa9c1445a1977cde8e44289d6be29ddbb315fb0fc1b45 + languageName: node + linkType: hard + "@babel/generator@npm:^7.7.2": version: 7.18.13 resolution: "@babel/generator@npm:7.18.13" @@ -1248,6 +1293,25 @@ __metadata: languageName: node linkType: hard +"@babel/helper-create-class-features-plugin@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/helper-create-class-features-plugin@npm:7.23.5" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-function-name": "npm:^7.23.0" + "@babel/helper-member-expression-to-functions": "npm:^7.23.0" + "@babel/helper-optimise-call-expression": "npm:^7.22.5" + "@babel/helper-replace-supers": "npm:^7.22.20" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + semver: "npm:^6.3.1" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: cd951e81b6a4ad79879f38edbe78d51cf29dfd5a7d33d7162aeaa3ac536dcc9a6679de8feb976bbd76d255a1654bf1742410517edd5c426fec66e0bf41eb8c45 + languageName: node + linkType: hard + "@babel/helper-create-regexp-features-plugin@npm:^7.18.6, @babel/helper-create-regexp-features-plugin@npm:^7.22.5": version: 7.22.9 resolution: "@babel/helper-create-regexp-features-plugin@npm:7.22.9" @@ -1364,6 +1428,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-member-expression-to-functions@npm:^7.22.15, @babel/helper-member-expression-to-functions@npm:^7.23.0": + version: 7.23.0 + resolution: "@babel/helper-member-expression-to-functions@npm:7.23.0" + dependencies: + "@babel/types": "npm:^7.23.0" + checksum: 325feb6e200478c8cd6e10433fabe993a7d3315cc1a2a457e45514a5f95a73dff4c69bea04cc2daea0ffe72d8ed85d504b3f00b2e0767b7d4f5ae25fec9b35b2 + languageName: node + linkType: hard + "@babel/helper-member-expression-to-functions@npm:^7.22.5": version: 7.22.5 resolution: "@babel/helper-member-expression-to-functions@npm:7.22.5" @@ -1488,6 +1561,19 @@ __metadata: languageName: node linkType: hard +"@babel/helper-replace-supers@npm:^7.22.20": + version: 7.22.20 + resolution: "@babel/helper-replace-supers@npm:7.22.20" + dependencies: + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-member-expression-to-functions": "npm:^7.22.15" + "@babel/helper-optimise-call-expression": "npm:^7.22.5" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 617666f57b0f94a2f430ee66b67c8f6fa94d4c22400f622947580d8f3638ea34b71280af59599ed4afbb54ae6e2bdd4f9083fe0e341184a4bb0bd26ef58d3017 + languageName: node + linkType: hard + "@babel/helper-replace-supers@npm:^7.22.5, @babel/helper-replace-supers@npm:^7.22.9": version: 7.22.9 resolution: "@babel/helper-replace-supers@npm:7.22.9" @@ -1567,6 +1653,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-string-parser@npm:^7.23.4": + version: 7.23.4 + resolution: "@babel/helper-string-parser@npm:7.23.4" + checksum: c352082474a2ee1d2b812bd116a56b2e8b38065df9678a32a535f151ec6f58e54633cc778778374f10544b930703cca6ddf998803888a636afa27e2658068a9c + languageName: node + linkType: hard + "@babel/helper-validator-identifier@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-validator-identifier@npm:7.18.6" @@ -1664,6 +1757,17 @@ __metadata: languageName: node linkType: hard +"@babel/helpers@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/helpers@npm:7.23.5" + dependencies: + "@babel/template": "npm:^7.22.15" + "@babel/traverse": "npm:^7.23.5" + "@babel/types": "npm:^7.23.5" + checksum: 84a813db55e03b5f47cef1210eb22751dae5dc3605bf62ff9acd4c248d857f94cb43dc7299e0edcec9312b31088f0d77f881282df2957e65a322b5412801cc24 + languageName: node + linkType: hard + "@babel/highlight@npm:^7.18.6": version: 7.18.6 resolution: "@babel/highlight@npm:7.18.6" @@ -1697,6 +1801,17 @@ __metadata: languageName: node linkType: hard +"@babel/highlight@npm:^7.23.4": + version: 7.23.4 + resolution: "@babel/highlight@npm:7.23.4" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.22.20" + chalk: "npm:^2.4.2" + js-tokens: "npm:^4.0.0" + checksum: 62fef9b5bcea7131df4626d009029b1ae85332042f4648a4ce6e740c3fd23112603c740c45575caec62f260c96b11054d3be5987f4981a5479793579c3aac71f + languageName: node + linkType: hard + "@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.17.9, @babel/parser@npm:^7.18.10": version: 7.19.0 resolution: "@babel/parser@npm:7.19.0" @@ -1742,6 +1857,15 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.23.0, @babel/parser@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/parser@npm:7.23.5" + bin: + parser: ./bin/babel-parser.js + checksum: 828c250ace0c58f9dc311fd13ad3da34e86ed27a5c6b4183ce9d85be250e78eeb71a13f6d51a368c46f8cbe51106c726bfbb158bf46a89db3a168a0002d3050a + languageName: node + linkType: hard + "@babel/parser@npm:^7.23.3": version: 7.23.3 resolution: "@babel/parser@npm:7.23.3" @@ -1898,6 +2022,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-flow@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/plugin-syntax-flow@npm:7.23.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: c6e6f355d6ace5f4a9e7bb19f1fed2398aeb9b62c4c671a189d81b124f9f5bb77c4225b6e85e19339268c60a021c1e49104e450375de5e6bb70612190d9678af + languageName: node + linkType: hard + "@babel/plugin-syntax-import-assertions@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-syntax-import-assertions@npm:7.22.5" @@ -1964,6 +2099,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/plugin-syntax-jsx@npm:7.23.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 89037694314a74e7f0e7a9c8d3793af5bf6b23d80950c29b360db1c66859d67f60711ea437e70ad6b5b4b29affe17eababda841b6c01107c2b638e0493bafb4e + languageName: node + linkType: hard + "@babel/plugin-syntax-logical-assignment-operators@npm:^7.10.4, @babel/plugin-syntax-logical-assignment-operators@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-logical-assignment-operators@npm:7.10.4" @@ -2063,6 +2209,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-typescript@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/plugin-syntax-typescript@npm:7.23.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: abfad3a19290d258b028e285a1f34c9b8a0cbe46ef79eafed4ed7ffce11b5d0720b5e536c82f91cbd8442cde35a3dd8e861fa70366d87ff06fdc0d4756e30876 + languageName: node + linkType: hard + "@babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.18.6 resolution: "@babel/plugin-syntax-typescript@npm:7.18.6" @@ -2284,6 +2441,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-flow-strip-types@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/plugin-transform-flow-strip-types@npm:7.23.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-flow": "npm:^7.23.3" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 84af4b1f6d79f1a66a2440c5cfe3ba0e2bb9355402da477add13de1867088efb8d7b2be15d67ac955f1d2a745d4a561423bbb473fe6e4622b157989598ec323f + languageName: node + linkType: hard + "@babel/plugin-transform-for-of@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-transform-for-of@npm:7.22.5" @@ -2379,6 +2548,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-modules-commonjs@npm:^7.23.0, @babel/plugin-transform-modules-commonjs@npm:^7.23.3": + version: 7.23.3 + resolution: "@babel/plugin-transform-modules-commonjs@npm:7.23.3" + dependencies: + "@babel/helper-module-transforms": "npm:^7.23.3" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-simple-access": "npm:^7.22.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: a3bc082d0dfe8327a29263a6d721cea608d440bc8141ba3ec6ba80ad73d84e4f9bbe903c27e9291c29878feec9b5dee2bd0563822f93dc951f5d7fc36bdfe85b + languageName: node + linkType: hard + "@babel/plugin-transform-modules-systemjs@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-transform-modules-systemjs@npm:7.22.5" @@ -2428,6 +2610,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.22.11": + version: 7.23.4 + resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.23.4" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-nullish-coalescing-operator": "npm:^7.8.3" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: a27d73ea134d3d9560a6b2e26ab60012fba15f1db95865aa0153c18f5ec82cfef6a7b3d8df74e3c2fca81534fa5efeb6cacaf7b08bdb7d123e3dafdd079886a3 + languageName: node + linkType: hard + "@babel/plugin-transform-nullish-coalescing-operator@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.22.5" @@ -2504,6 +2698,19 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-optional-chaining@npm:^7.23.0": + version: 7.23.4 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.23.4" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" + "@babel/plugin-syntax-optional-chaining": "npm:^7.8.3" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 0ef24e889d6151428953fc443af5f71f4dae73f373dc1b7f5dd3f6a61d511296eb77e9b870e8c2c02a933e3455ae24c1fa91738c826b72a4ff87e0337db527e8 + languageName: node + linkType: hard + "@babel/plugin-transform-parameters@npm:^7.22.5": version: 7.22.5 resolution: "@babel/plugin-transform-parameters@npm:7.22.5" @@ -2716,6 +2923,20 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-typescript@npm:^7.23.3": + version: 7.23.5 + resolution: "@babel/plugin-transform-typescript@npm:7.23.5" + dependencies: + "@babel/helper-annotate-as-pure": "npm:^7.22.5" + "@babel/helper-create-class-features-plugin": "npm:^7.23.5" + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/plugin-syntax-typescript": "npm:^7.23.3" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: f8cfea916092e3604b78aa9e84d342572023e61036d797c23730aeee6efdc6ac8a836d2a5c0588eacfc0b2e9482df8a820923f23b7cfe4e9bf92d9de9c5e499f + languageName: node + linkType: hard + "@babel/plugin-transform-unicode-escapes@npm:^7.22.10": version: 7.22.10 resolution: "@babel/plugin-transform-unicode-escapes@npm:7.22.10" @@ -2866,6 +3087,19 @@ __metadata: languageName: node linkType: hard +"@babel/preset-flow@npm:^7.22.15": + version: 7.23.3 + resolution: "@babel/preset-flow@npm:7.23.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-validator-option": "npm:^7.22.15" + "@babel/plugin-transform-flow-strip-types": "npm:^7.23.3" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 60b5dde79621ae89943af459c4dc5b6030795f595a20ca438c8100f8d82c9ebc986881719030521ff5925799518ac5aa7f3fe62af8c33ab96be3681a71f88d03 + languageName: node + linkType: hard + "@babel/preset-modules@npm:0.1.6-no-external-plugins": version: 0.1.6-no-external-plugins resolution: "@babel/preset-modules@npm:0.1.6-no-external-plugins" @@ -2910,6 +3144,21 @@ __metadata: languageName: node linkType: hard +"@babel/preset-typescript@npm:^7.23.0": + version: 7.23.3 + resolution: "@babel/preset-typescript@npm:7.23.3" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.22.5" + "@babel/helper-validator-option": "npm:^7.22.15" + "@babel/plugin-syntax-jsx": "npm:^7.23.3" + "@babel/plugin-transform-modules-commonjs": "npm:^7.23.3" + "@babel/plugin-transform-typescript": "npm:^7.23.3" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: c4add0f3fcbb3f4a305c48db9ccb32694f1308ed9971ccbc1a8a3c76d5a13726addb3c667958092287d7aa080186c5c83dbfefa55eacf94657e6cde39e172848 + languageName: node + linkType: hard + "@babel/register@npm:^7.13.16": version: 7.22.5 resolution: "@babel/register@npm:7.22.5" @@ -2925,6 +3174,21 @@ __metadata: languageName: node linkType: hard +"@babel/register@npm:^7.22.15": + version: 7.22.15 + resolution: "@babel/register@npm:7.22.15" + dependencies: + clone-deep: "npm:^4.0.1" + find-cache-dir: "npm:^2.0.0" + make-dir: "npm:^2.1.0" + pirates: "npm:^4.0.5" + source-map-support: "npm:^0.5.16" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 5497be6773608cd2d874210edd14499fce464ddbea170219da55955afe4c9173adb591164193458fd639e43b7d1314088a6186f4abf241476c59b3f0da6afd6f + languageName: node + linkType: hard + "@babel/regjsgen@npm:^0.8.0": version: 0.8.0 resolution: "@babel/regjsgen@npm:0.8.0" @@ -3094,6 +3358,24 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/traverse@npm:7.23.5" + dependencies: + "@babel/code-frame": "npm:^7.23.5" + "@babel/generator": "npm:^7.23.5" + "@babel/helper-environment-visitor": "npm:^7.22.20" + "@babel/helper-function-name": "npm:^7.23.0" + "@babel/helper-hoist-variables": "npm:^7.22.5" + "@babel/helper-split-export-declaration": "npm:^7.22.6" + "@babel/parser": "npm:^7.23.5" + "@babel/types": "npm:^7.23.5" + debug: "npm:^4.1.0" + globals: "npm:^11.1.0" + checksum: 281cae2765caad88c7af6214eab3647db0e9cadc7ffcd3fd924f09fbb9bd09d97d6fb210794b7545c317ce417a30016636530043a455ba6922349e39c1ba622a + languageName: node + linkType: hard + "@babel/traverse@npm:^7.4.5": version: 7.17.9 resolution: "@babel/traverse@npm:7.17.9" @@ -3238,6 +3520,17 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/types@npm:7.23.5" + dependencies: + "@babel/helper-string-parser": "npm:^7.23.4" + "@babel/helper-validator-identifier": "npm:^7.22.20" + to-fast-properties: "npm:^2.0.0" + checksum: a623a4e7f396f1903659099da25bfa059694a49f42820f6b5288347f1646f0b37fb7cc550ba45644e9067149368ef34ccb1bd4a4251ec59b83b3f7765088f363 + languageName: node + linkType: hard + "@babel/types@npm:^7.8.3": version: 7.21.3 resolution: "@babel/types@npm:7.21.3" @@ -5052,6 +5345,22 @@ __metadata: languageName: node linkType: hard +"@kwsites/file-exists@npm:^1.1.1": + version: 1.1.1 + resolution: "@kwsites/file-exists@npm:1.1.1" + dependencies: + debug: "npm:^4.1.1" + checksum: 4ff945de7293285133aeae759caddc71e73c4a44a12fac710fdd4f574cce2671a3f89d8165fdb03d383cfc97f3f96f677d8de3c95133da3d0e12a123a23109fe + languageName: node + linkType: hard + +"@kwsites/promise-deferred@npm:^1.1.1": + version: 1.1.1 + resolution: "@kwsites/promise-deferred@npm:1.1.1" + checksum: 07455477a0123d9a38afb503739eeff2c5424afa8d3dbdcc7f9502f13604488a4b1d9742fc7288832a52a6422cf1e1c0a1d51f69a39052f14d27c9a0420b6629 + languageName: node + linkType: hard + "@lerna/child-process@npm:6.6.2": version: 6.6.2 resolution: "@lerna/child-process@npm:6.6.2" @@ -8624,7 +8933,7 @@ __metadata: fork-ts-checker-webpack-plugin: "npm:8.0.0" formik: "npm:2.4.0" fractional-indexing: "npm:3.2.0" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" highlight.js: "npm:^10.4.1" history: "npm:^4.9.0" html-webpack-plugin: "npm:5.5.0" @@ -8726,7 +9035,7 @@ __metadata: chalk: "npm:4.1.2" cli-table3: "npm:0.6.2" commander: "npm:8.3.0" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" inquirer: "npm:8.2.5" knex: "npm:3.0.1" koa: "npm:2.13.4" @@ -8755,7 +9064,7 @@ __metadata: date-fns: "npm:2.30.0" debug: "npm:4.3.4" eslint-config-custom: "npm:4.15.5" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" knex: "npm:3.0.1" lodash: "npm:4.17.21" semver: "npm:7.5.4" @@ -8831,7 +9140,7 @@ __metadata: chalk: "npm:^4.1.2" copyfiles: "npm:2.4.1" execa: "npm:5.1.1" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" inquirer: "npm:8.2.5" lodash: "npm:4.17.21" node-machine-id: "npm:^1.1.10" @@ -8852,7 +9161,7 @@ __metadata: chalk: "npm:4.1.2" copyfiles: "npm:2.4.1" eslint-config-custom: "npm:4.15.5" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" node-plop: "npm:0.26.3" plop: "npm:2.7.6" pluralize: "npm:8.0.0" @@ -9076,7 +9385,7 @@ __metadata: "@testing-library/react-hooks": "npm:8.0.1" "@testing-library/user-event": "npm:14.4.3" "@types/pluralize": "npm:0.0.30" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" history: "npm:^4.9.0" immer: "npm:9.0.19" koa: "npm:2.13.4" @@ -9118,7 +9427,7 @@ __metadata: bcryptjs: "npm:2.4.3" cheerio: "npm:^1.0.0-rc.12" formik: "npm:2.4.0" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" immer: "npm:9.0.19" koa-static: "npm:^5.0.0" lodash: "npm:4.17.21" @@ -9300,7 +9609,7 @@ __metadata: cropperjs: "npm:1.6.0" date-fns: "npm:2.30.0" formik: "npm:2.4.0" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" immer: "npm:9.0.19" koa-range: "npm:0.3.0" koa-static: "npm:5.0.0" @@ -9484,7 +9793,7 @@ __metadata: "@strapi/utils": "npm:4.15.5" "@types/jest": "npm:29.5.2" eslint-config-custom: "npm:4.15.5" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" tsconfig: "npm:4.15.5" languageName: unknown linkType: soft @@ -9539,8 +9848,8 @@ __metadata: dotenv: "npm:14.2.0" eslint-config-custom: "npm:4.15.5" execa: "npm:5.1.1" - fs-extra: "npm:10.0.0" - glob: "npm:7.2.3" + fs-extra: "npm:10.1.0" + glob: "npm:10.3.10" http-errors: "npm:1.8.1" inquirer: "npm:8.2.5" is-docker: "npm:2.2.1" @@ -9617,7 +9926,7 @@ __metadata: dependencies: chalk: "npm:4.1.2" cli-table3: "npm:0.6.2" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" lodash: "npm:4.17.21" prettier: "npm:2.8.4" typescript: "npm:5.3.2" @@ -9656,6 +9965,33 @@ __metadata: languageName: node linkType: hard +"@strapi/upgrade@workspace:packages/utils/upgrade": + version: 0.0.0-use.local + resolution: "@strapi/upgrade@workspace:packages/utils/upgrade" + dependencies: + "@strapi/pack-up": "workspace:*" + "@strapi/types": "npm:4.15.5" + "@types/jscodeshift": "npm:0.11.10" + chalk: "npm:4.1.2" + cli-table3: "npm:0.6.2" + commander: "npm:8.3.0" + esbuild-register: "npm:3.5.0" + eslint-config-custom: "workspace:*" + fs-extra: "npm:10.1.0" + glob: "npm:10.3.10" + jscodeshift: "npm:0.15.1" + lodash: "npm:4.17.21" + memfs: "npm:4.6.0" + ora: "npm:5.4.1" + prompts: "npm:2.4.2" + rimraf: "npm:3.0.2" + semver: "npm:7.5.4" + simple-git: "npm:3.21.0" + bin: + upgrade: ./bin/upgrade.js + languageName: unknown + linkType: soft + "@strapi/utils@npm:4.15.5, @strapi/utils@workspace:packages/core/utils": version: 0.0.0-use.local resolution: "@strapi/utils@workspace:packages/core/utils" @@ -10519,6 +10855,16 @@ __metadata: languageName: node linkType: hard +"@types/jscodeshift@npm:0.11.10": + version: 0.11.10 + resolution: "@types/jscodeshift@npm:0.11.10" + dependencies: + ast-types: "npm:^0.14.1" + recast: "npm:^0.20.3" + checksum: 6f4a84fe28202d5af7ec829ca6a34b519ae5fd1b39a46985ff1e35b044cda59f35db44ae5ff73dc4ecbd323fd3afbafd28de029136ea3bd109940a757c14c8af + languageName: node + linkType: hard + "@types/jsdom@npm:^20.0.0": version: 20.0.0 resolution: "@types/jsdom@npm:20.0.0" @@ -12547,6 +12893,13 @@ __metadata: languageName: node linkType: hard +"arg@npm:^5.0.2": + version: 5.0.2 + resolution: "arg@npm:5.0.2" + checksum: 92fe7de222054a060fd2329e92e867410b3ea260328147ee3fb7855f78efae005f4087e698d4e688a856893c56bb09951588c40f2c901cf6996cd8cd7bcfef2c + languageName: node + linkType: hard + "argparse@npm:^1.0.7, argparse@npm:~1.0.9": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -12835,6 +13188,15 @@ __metadata: languageName: node linkType: hard +"ast-types@npm:0.14.2, ast-types@npm:^0.14.1": + version: 0.14.2 + resolution: "ast-types@npm:0.14.2" + dependencies: + tslib: "npm:^2.0.1" + checksum: 7c74b3090c90aa600b49a7a8cecc99e329f190600bcaa75ad087472a1a5a7ef23795a17ea00a74c2a8e822b336cd4f874e2e1b815a9877b4dba5e401566b0433 + languageName: node + linkType: hard + "ast-types@npm:0.15.2": version: 0.15.2 resolution: "ast-types@npm:0.15.2" @@ -15159,7 +15521,7 @@ __metadata: commander: "npm:8.3.0" eslint-config-custom: "npm:4.15.5" execa: "npm:5.1.1" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" inquirer: "npm:8.2.5" ora: "npm:5.4.1" tsconfig: "npm:4.15.5" @@ -16958,7 +17320,7 @@ __metadata: languageName: node linkType: hard -"eslint-config-custom@npm:4.15.5, eslint-config-custom@workspace:packages/utils/eslint-config-custom": +"eslint-config-custom@npm:4.15.5, eslint-config-custom@workspace:*, eslint-config-custom@workspace:packages/utils/eslint-config-custom": version: 0.0.0-use.local resolution: "eslint-config-custom@workspace:packages/utils/eslint-config-custom" languageName: unknown @@ -18393,14 +18755,14 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:10.0.0": - version: 10.0.0 - resolution: "fs-extra@npm:10.0.0" +"fs-extra@npm:10.1.0, fs-extra@npm:^10.0.0": + version: 10.1.0 + resolution: "fs-extra@npm:10.1.0" dependencies: graceful-fs: "npm:^4.2.0" jsonfile: "npm:^6.0.1" universalify: "npm:^2.0.0" - checksum: c333973ece06655bf9a4566c65e648d64d7ab3a36e52011b05263e8f4664d2cc85cf9d98ddd4672857105561d759ef63828cf49428c78470a788a3751094a1a4 + checksum: 05ce2c3b59049bcb7b52001acd000e44b3c4af4ec1f8839f383ef41ec0048e3cfa7fd8a637b1bddfefad319145db89be91f4b7c1db2908205d38bf91e7d1d3b7 languageName: node linkType: hard @@ -18427,17 +18789,6 @@ __metadata: languageName: node linkType: hard -"fs-extra@npm:^10.0.0": - version: 10.1.0 - resolution: "fs-extra@npm:10.1.0" - dependencies: - graceful-fs: "npm:^4.2.0" - jsonfile: "npm:^6.0.1" - universalify: "npm:^2.0.0" - checksum: 05ce2c3b59049bcb7b52001acd000e44b3c4af4ec1f8839f383ef41ec0048e3cfa7fd8a637b1bddfefad319145db89be91f4b7c1db2908205d38bf91e7d1d3b7 - languageName: node - linkType: hard - "fs-extra@npm:^11.1.0": version: 11.1.0 resolution: "fs-extra@npm:11.1.0" @@ -18932,6 +19283,21 @@ __metadata: languageName: node linkType: hard +"glob@npm:10.3.10": + version: 10.3.10 + resolution: "glob@npm:10.3.10" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^2.3.5" + minimatch: "npm:^9.0.1" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry: "npm:^1.10.1" + bin: + glob: dist/esm/bin.mjs + checksum: 38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8 + languageName: node + linkType: hard + "glob@npm:7.1.4": version: 7.1.4 resolution: "glob@npm:7.1.4" @@ -19845,6 +20211,13 @@ __metadata: languageName: node linkType: hard +"hyperdyperid@npm:^1.2.0": + version: 1.2.0 + resolution: "hyperdyperid@npm:1.2.0" + checksum: 64abb5568ff17aa08ac0175ae55e46e22831c5552be98acdd1692081db0209f36fff58b31432017b4e1772c178962676a2cc3c54e4d5d7f020d7710cec7ad7a6 + languageName: node + linkType: hard + "iconv-lite@npm:0.4.13": version: 0.4.13 resolution: "iconv-lite@npm:0.4.13" @@ -21067,6 +21440,19 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^2.3.5": + version: 2.3.6 + resolution: "jackspeak@npm:2.3.6" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 6e6490d676af8c94a7b5b29b8fd5629f21346911ebe2e32931c2a54210134408171c24cee1a109df2ec19894ad04a429402a8438cbf5cc2794585d35428ace76 + languageName: node + linkType: hard + "jake@npm:^10.8.5": version: 10.8.5 resolution: "jake@npm:10.8.5" @@ -21674,6 +22060,41 @@ __metadata: languageName: node linkType: hard +"jscodeshift@npm:0.15.1": + version: 0.15.1 + resolution: "jscodeshift@npm:0.15.1" + dependencies: + "@babel/core": "npm:^7.23.0" + "@babel/parser": "npm:^7.23.0" + "@babel/plugin-transform-class-properties": "npm:^7.22.5" + "@babel/plugin-transform-modules-commonjs": "npm:^7.23.0" + "@babel/plugin-transform-nullish-coalescing-operator": "npm:^7.22.11" + "@babel/plugin-transform-optional-chaining": "npm:^7.23.0" + "@babel/plugin-transform-private-methods": "npm:^7.22.5" + "@babel/preset-flow": "npm:^7.22.15" + "@babel/preset-typescript": "npm:^7.23.0" + "@babel/register": "npm:^7.22.15" + babel-core: "npm:^7.0.0-bridge.0" + chalk: "npm:^4.1.2" + flow-parser: "npm:0.*" + graceful-fs: "npm:^4.2.4" + micromatch: "npm:^4.0.4" + neo-async: "npm:^2.5.0" + node-dir: "npm:^0.1.17" + recast: "npm:^0.23.3" + temp: "npm:^0.8.4" + write-file-atomic: "npm:^2.3.0" + peerDependencies: + "@babel/preset-env": ^7.1.6 + peerDependenciesMeta: + "@babel/preset-env": + optional: true + bin: + jscodeshift: bin/jscodeshift.js + checksum: 7cece7b99fe57de7d65bdd962c93b93f0080605cf7d7f1aad42da7c3beb824107067726ede681b703fd012293b7797b7f2fefbb1420b0e44a0fca669bb48e34c + languageName: node + linkType: hard + "jscodeshift@npm:^0.14.0": version: 0.14.0 resolution: "jscodeshift@npm:0.14.0" @@ -21770,6 +22191,29 @@ __metadata: languageName: node linkType: hard +"json-joy@npm:^9.2.0": + version: 9.9.1 + resolution: "json-joy@npm:9.9.1" + dependencies: + arg: "npm:^5.0.2" + hyperdyperid: "npm:^1.2.0" + peerDependencies: + quill-delta: ^5 + rxjs: 7 + tslib: 2 + bin: + jj: bin/jj.js + json-pack: bin/json-pack.js + json-pack-test: bin/json-pack-test.js + json-patch: bin/json-patch.js + json-patch-test: bin/json-patch-test.js + json-pointer: bin/json-pointer.js + json-pointer-test: bin/json-pointer-test.js + json-unpack: bin/json-unpack.js + checksum: 580cf35465b838a4fe96dc90be1d8d9b95e3c495fb5e48ad4f5b048fc5173ec47c6fd58fb93728ede42b3f8bbce0344484ac0986f9c3fa9be2f1957c4eedd872 + languageName: node + linkType: hard + "json-parse-better-errors@npm:^1.0.1": version: 1.0.2 resolution: "json-parse-better-errors@npm:1.0.2" @@ -23451,6 +23895,18 @@ __metadata: languageName: node linkType: hard +"memfs@npm:4.6.0": + version: 4.6.0 + resolution: "memfs@npm:4.6.0" + dependencies: + json-joy: "npm:^9.2.0" + thingies: "npm:^1.11.1" + peerDependencies: + tslib: 2 + checksum: 640071c277821a5a1564795caf2cc264e383733b94d259abebc5f21f6832789bdf13ce873468383111685d49657f2e9f29fc85c7bb64ff55757bed186cf074ff + languageName: node + linkType: hard + "memfs@npm:^3.4.1, memfs@npm:^3.4.12": version: 3.5.3 resolution: "memfs@npm:3.5.3" @@ -27688,6 +28144,18 @@ __metadata: languageName: node linkType: hard +"recast@npm:^0.20.3": + version: 0.20.5 + resolution: "recast@npm:0.20.5" + dependencies: + ast-types: "npm:0.14.2" + esprima: "npm:~4.0.0" + source-map: "npm:~0.6.1" + tslib: "npm:^2.0.1" + checksum: 7b270187e12f06ba0f5695590158005170a49a5996ab5d30ec4af2a2b1db8b0f74b1449b7eb6984f6d381438448e05cb46dcbf9b647fc49c6fc5139b2e40fca0 + languageName: node + linkType: hard + "recast@npm:^0.21.0": version: 0.21.5 resolution: "recast@npm:0.21.5" @@ -27713,6 +28181,19 @@ __metadata: languageName: node linkType: hard +"recast@npm:^0.23.3": + version: 0.23.4 + resolution: "recast@npm:0.23.4" + dependencies: + assert: "npm:^2.0.0" + ast-types: "npm:^0.16.1" + esprima: "npm:~4.0.0" + source-map: "npm:~0.6.1" + tslib: "npm:^2.0.1" + checksum: a82e388ded2154697ea54e6d65d060143c9cf4b521f770232a7483e253d45bdd9080b44dc5874d36fe720ba1a10cb20b95375896bd89f5cab631a751e93979f5 + languageName: node + linkType: hard + "rechoir@npm:^0.6.2": version: 0.6.2 resolution: "rechoir@npm:0.6.2" @@ -28938,6 +29419,17 @@ __metadata: languageName: node linkType: hard +"simple-git@npm:3.21.0": + version: 3.21.0 + resolution: "simple-git@npm:3.21.0" + dependencies: + "@kwsites/file-exists": "npm:^1.1.1" + "@kwsites/promise-deferred": "npm:^1.1.1" + debug: "npm:^4.3.4" + checksum: 6b644151a41facdafdb6ef97f52125cfcfa61e1aa4bed1f25249d4ae71f9ddaffd371919f9dd0cc3fdb16db248d98b389f80ae4f2a416d924f23e6cee3b2f813 + languageName: node + linkType: hard + "simple-swizzle@npm:^0.2.2": version: 0.2.2 resolution: "simple-swizzle@npm:0.2.2" @@ -29513,7 +30005,7 @@ __metadata: eslint-plugin-testing-library: "npm:6.0.2" execa: "npm:5.1.1" find-up: "npm:5.0.0" - fs-extra: "npm:10.0.0" + fs-extra: "npm:10.1.0" get-port: "npm:5.1.1" glob: "npm:7.2.3" husky: "npm:8.0.2" @@ -30357,6 +30849,15 @@ __metadata: languageName: node linkType: hard +"thingies@npm:^1.11.1": + version: 1.15.0 + resolution: "thingies@npm:1.15.0" + peerDependencies: + tslib: ^2 + checksum: c19fd69fe4772f039e2310d633f3c597bd1edb925f7d76233e2198c148ff5715272b1b493ecfcf0ebdecff195a885d80e5c9ed7c0c4348f288d885148ac4bdb7 + languageName: node + linkType: hard + "through2@npm:^2.0.0, through2@npm:^2.0.1, through2@npm:^2.0.3, through2@npm:~2.0.3": version: 2.0.5 resolution: "through2@npm:2.0.5"