diff --git a/.changeset/grumpy-swans-think.md b/.changeset/grumpy-swans-think.md new file mode 100644 index 000000000..8d545a275 --- /dev/null +++ b/.changeset/grumpy-swans-think.md @@ -0,0 +1,5 @@ +--- +'@codeshift/validator': minor +--- + +Fundamentally simplifies and improves on how validation works. diff --git a/.changeset/silly-icons-whisper.md b/.changeset/silly-icons-whisper.md new file mode 100644 index 000000000..9ff93b573 --- /dev/null +++ b/.changeset/silly-icons-whisper.md @@ -0,0 +1,5 @@ +--- +'@codeshift/types': patch +--- + +Initial release diff --git a/.changeset/slimy-foxes-train.md b/.changeset/slimy-foxes-train.md new file mode 100644 index 000000000..b9a7fe781 --- /dev/null +++ b/.changeset/slimy-foxes-train.md @@ -0,0 +1,5 @@ +--- +'@codeshift/cli': minor +--- + +Codemods can now be sourced from standalone npm packages such as react as long as they provide a codeshift.config.js. This allows for greater flexibility for where codemods may be distributed diff --git a/packages/cli/package.json b/packages/cli/package.json index 533859365..9e4331b00 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,7 @@ "fs-extra": "^9.1.0", "jscodeshift": "^0.12.0", "live-plugin-manager": "^0.15.1", + "lodash": "^4.17.21", "semver": "^7.3.5", "ts-node": "^9.1.1" } diff --git a/packages/cli/src/main.spec.ts b/packages/cli/src/main.spec.ts index 66a3c3607..581c0c3a1 100644 --- a/packages/cli/src/main.spec.ts +++ b/packages/cli/src/main.spec.ts @@ -6,29 +6,12 @@ jest.mock('jscodeshift/src/Runner', () => ({ // @ts-ignore import * as jscodeshift from 'jscodeshift/src/Runner'; import { PluginManager } from 'live-plugin-manager'; + import main from './main'; const mockPath = 'src/pages/home-page/'; describe('main', () => { - beforeEach(() => { - (PluginManager as jest.Mock).mockReturnValue({ - install: () => Promise.resolve(undefined), - require: (codemodName: string) => ({ - transforms: { - '18.0.0': `${codemodName}/path/to/18.js`, - '19.0.0': `${codemodName}/path/to/19.js`, - '20.0.0': `${codemodName}/path/to/20.js`, - }, - presets: { - 'update-formatting': `${codemodName}/path/to/update-formatting.js`, - 'update-imports': `${codemodName}/path/to/update-imports.js`, - }, - }), - uninstallAll: () => Promise.resolve(), - }); - }); - afterEach(() => { jest.resetAllMocks(); }); @@ -134,6 +117,26 @@ describe('main', () => { }); describe('when running transforms with the -p flag', () => { + beforeEach(() => { + (PluginManager as jest.Mock).mockImplementation(() => ({ + install: jest.fn().mockResolvedValue(undefined), + require: jest.fn().mockImplementation((codemodName: string) => { + if (!codemodName.startsWith('@codeshift')) { + throw new Error('Attempted to fetch codemod from npm'); + } + + return { + transforms: { + '18.0.0': `${codemodName}/path/to/18.js`, + '19.0.0': `${codemodName}/path/to/19.js`, + '20.0.0': `${codemodName}/path/to/20.js`, + }, + }; + }), + uninstallAll: jest.fn().mockResolvedValue(undefined), + })); + }); + it('should run package transform for single version', async () => { await main([mockPath], { packages: 'mylib@18.0.0', @@ -234,6 +237,7 @@ describe('main', () => { expect.any(Object), ); }); + it('should run multiple transforms of the same package', async () => { await main([mockPath], { packages: '@myscope/mylib@20.0.0@19.0.0', @@ -374,6 +378,25 @@ describe('main', () => { }); describe('when running presets with the -p flag', () => { + beforeEach(() => { + (PluginManager as jest.Mock).mockImplementation(() => ({ + install: jest.fn().mockResolvedValue(undefined), + require: jest.fn().mockImplementation((codemodName: string) => { + if (!codemodName.startsWith('@codeshift')) { + throw new Error('Attempted to fetch codemod from npm'); + } + + return { + presets: { + 'update-formatting': `${codemodName}/path/to/update-formatting.js`, + 'update-imports': `${codemodName}/path/to/update-imports.js`, + }, + }; + }), + uninstallAll: jest.fn().mockResolvedValue(undefined), + })); + }); + it('should run single preset', async () => { await main([mockPath], { packages: 'mylib#update-formatting', @@ -508,18 +531,71 @@ describe('main', () => { }); }); + describe('when running transforms from NPM with the -p flag', () => { + beforeEach(() => { + (PluginManager as jest.Mock).mockImplementation(() => ({ + install: jest.fn().mockResolvedValue(undefined), + require: jest.fn().mockImplementation((codemodName: string) => { + if (codemodName.startsWith('@codeshift')) { + throw new Error('Attempted to fetch codemod from community folder'); + } + + return { + transforms: { + '18.0.0': `${codemodName}/path/to/18.js`, + }, + presets: { + 'update-formatting': `${codemodName}/path/to/update-formatting.js`, + }, + }; + }), + uninstallAll: jest.fn().mockResolvedValue(undefined), + })); + }); + + it('should run package transform for single version', async () => { + await main([mockPath], { + packages: 'mylib@18.0.0', + parser: 'babel', + extensions: 'js', + }); + + expect(jscodeshift.run).toHaveBeenCalledTimes(1); + expect(jscodeshift.run).toHaveBeenCalledWith( + 'mylib/path/to/18.js', + expect.arrayContaining([mockPath]), + expect.anything(), + ); + }); + + it('should run single preset', async () => { + await main([mockPath], { + packages: 'mylib#update-formatting', + parser: 'babel', + extensions: 'js', + }); + + expect(jscodeshift.run).toHaveBeenCalledTimes(1); + expect(jscodeshift.run).toHaveBeenCalledWith( + 'mylib/path/to/update-formatting.js', + expect.arrayContaining([mockPath]), + expect.anything(), + ); + }); + }); + describe('when reading configs using non-cjs exports', () => { it('should read configs exported with export default', async () => { (PluginManager as jest.Mock).mockReturnValue({ install: () => Promise.resolve(undefined), // @ts-ignore - require: (codemodName: string) => ({ + require: jest.fn().mockImplementationOnce((codemodName: string) => ({ default: { transforms: { '18.0.0': `${codemodName}/path/to/18.js`, }, }, - }), + })), uninstallAll: () => Promise.resolve(), }); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 5ae27236b..e35ac4eeb 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,13 +1,84 @@ import semver from 'semver'; import chalk from 'chalk'; +import path from 'path'; import { PluginManager } from 'live-plugin-manager'; +import merge from 'lodash/merge'; // @ts-ignore Run transform(s) on path https://github.com/facebook/jscodeshift/issues/398 import * as jscodeshift from 'jscodeshift/src/Runner'; +import { isValidConfig } from '@codeshift/validator'; +import { CodeshiftConfig } from '@codeshift/types'; import { Flags } from './types'; import { InvalidUserInputError } from './errors'; +async function fetchCommunityPackageConfig( + packageName: string, + packageManager: PluginManager, +) { + const pkgName = packageName.replace('@', '').replace('/', '__'); + const commPackageName = `@codeshift/mod-${pkgName}`; + + await packageManager.install(commPackageName); + const pkg = packageManager.require(commPackageName); + const config: CodeshiftConfig = pkg.default ? pkg.default : pkg; + + if (!isValidConfig(config)) { + throw new Error(`Invalid config found in module ${commPackageName}`); + } + + return config; +} + +async function fetchRemotePackageConfig( + packageName: string, + packageManager: PluginManager, +) { + await packageManager.install(packageName); + const pkg = packageManager.require(packageName); + + if (pkg) { + const config: CodeshiftConfig = pkg.default ? pkg.default : pkg; + + if (config && isValidConfig(config)) { + // Found a config at the main entry-point + return config; + } + } + + const info = packageManager.getInfo(packageName); + + if (info) { + let config: CodeshiftConfig | undefined; + + [ + path.join(info?.location, 'codeshift.config.js'), + path.join(info?.location, 'codeshift.config.ts'), + path.join(info?.location, 'src', 'codeshift.config.js'), + path.join(info?.location, 'src', 'codeshift.config.ts'), + path.join(info?.location, 'codemods', 'codeshift.config.js'), + path.join(info?.location, 'codemods', 'codeshift.config.ts'), + ].forEach(searchPath => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkg = require(searchPath); + const searchConfig: CodeshiftConfig = pkg.default ? pkg.default : pkg; + + if (isValidConfig(searchConfig)) { + config = searchConfig; + } + } catch (e) {} + }); + + if (config) return config; + } + + throw new Error( + `Unable to locate a valid codeshift.config in package ${packageName}`, + ); +} + export default async function main(paths: string[], flags: Flags) { + const packageManager = new PluginManager(); let transforms: string[] = []; if (!flags.transform && !flags.packages) { @@ -26,24 +97,36 @@ export default async function main(paths: string[], flags: Flags) { transforms.push(flags.transform); } - const packageManager = new PluginManager(); - if (flags.packages) { const pkgs = flags.packages.split(',').filter(pkg => !!pkg); for (const pkg of pkgs) { - const pkgName = pkg - .split(/[@#]/) - .filter(str => !!str)[0] - .replace('/', '__'); - const packageName = `@codeshift/mod-${pkgName}`; - - await packageManager.install(packageName); - const codeshiftPackage = packageManager.require(packageName); - - const config = codeshiftPackage.default - ? codeshiftPackage.default - : codeshiftPackage; + const shouldPrependAtSymbol = pkg.startsWith('@') ? '@' : ''; + const pkgName = + shouldPrependAtSymbol + pkg.split(/[@#]/).filter(str => !!str)[0]; + + let communityConfig; + let remoteConfig; + + try { + communityConfig = await fetchCommunityPackageConfig( + pkgName, + packageManager, + ); + } catch (error) {} + + try { + remoteConfig = await fetchRemotePackageConfig(pkgName, packageManager); + } catch (error) {} + + if (!communityConfig && !remoteConfig) { + throw new Error( + `Unable to locate package from the codeshift-community packages or as a standalone NPM package. +Make sure the package name ${pkgName} has been spelled correctly and exists before trying again.`, + ); + } + + const config: CodeshiftConfig = merge({}, communityConfig, remoteConfig); const rawTransformIds = pkg.split(/(?=[@#])/).filter(str => !!str); rawTransformIds.shift(); diff --git a/packages/cli/src/validate.ts b/packages/cli/src/validate.ts index eac725fbe..290524236 100644 --- a/packages/cli/src/validate.ts +++ b/packages/cli/src/validate.ts @@ -1,8 +1,8 @@ -import { isValidConfig, isValidPackageJson } from '@codeshift/validator'; +import { isValidConfigAtPath, isValidPackageJson } from '@codeshift/validator'; export default async function validate(targetPath: string = '.') { try { - await isValidConfig(targetPath); + await isValidConfigAtPath(targetPath); await isValidPackageJson(targetPath); } catch (error) { console.warn(error); diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100755 index 000000000..6b2f3538a --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1 @@ +# @codeshift/types diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 000000000..1dc883240 --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,9 @@ +{ + "name": "@codeshift/types", + "version": "0.0.1", + "main": "dist/codeshift-types.cjs.js", + "module": "dist/codeshift-types.esm.js", + "types": "dist/codeshift-types.cjs.d.ts", + "license": "MIT", + "repository": "https://github.com/CodeshiftCommunity/CodeshiftCommunity/tree/master/packages/types" +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 000000000..25616f495 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,7 @@ +export interface CodeshiftConfig { + target?: string[]; + maintainers?: string[]; + description?: string; + transforms?: Record; + presets?: Record; +} diff --git a/packages/validator/package.json b/packages/validator/package.json index 032dc4430..2b0a6138d 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -6,9 +6,14 @@ "license": "MIT", "repository": "https://github.com/CodeshiftCommunity/CodeshiftCommunity/tree/master/packages/validator", "dependencies": { + "@codeshift/types": "^0.0.1", "fs-extra": "^9.1.0", + "lodash": "^4.17.21", "recast": "^0.20.4", "semver": "^7.3.5", "ts-node": "^9.1.1" + }, + "devDependencies": { + "@types/lodash": "^4.14.176" } } diff --git a/packages/validator/src/index.ts b/packages/validator/src/index.ts index 759a2829d..1456a8bef 100644 --- a/packages/validator/src/index.ts +++ b/packages/validator/src/index.ts @@ -1,50 +1,59 @@ import fs from 'fs-extra'; import semver from 'semver'; import path from 'path'; +import { CodeshiftConfig } from '@codeshift/types'; -export function isValidPackageName(dir: string) { - return dir.match(/^(@[a-z0-9-~][a-z0-9-._~]*__)?[a-z0-9-~][a-z0-9-._~]*$/); -} - -export async function isValidConfig(filePath: string) { +function getConfigFromPath(filePath: string): CodeshiftConfig { const configPath = path.join(process.cwd(), filePath, 'codeshift.config.js'); // eslint-disable-next-line @typescript-eslint/no-var-requires - let config = require(configPath); + const config = require(configPath); - config = !!config.default ? config.default : config; + return !!config.default ? config.default : config; +} - const invalidSemverIds = []; - const invalidPresetIds = []; +function hasValidTransforms(transforms?: Record) { + if (!transforms || !Object.keys(transforms).length) return false; - let hasTransforms = false; + return Object.entries(transforms).every(([key]) => semver.valid(key)); +} - if (config.transforms && Object.keys(config.transforms).length) { - Object.entries(config.transforms).forEach(([key]) => { - hasTransforms = true; - if (!semver.valid(key)) invalidSemverIds.push(key); - }); - } +function hasValidPresets(presets?: Record): boolean { + if (!presets || !Object.keys(presets).length) return false; - if (config.presets && Object.keys(config.presets).length) { - hasTransforms = true; - Object.entries(config.presets).forEach(([key]) => { - if (key.includes(' ')) invalidPresetIds.push(key); - }); - } + return Object.entries(presets).every(([key]) => + key.match(/^[0-9a-zA-Z\-]+$/), + ); +} - if (!hasTransforms) { +export function isValidPackageName(dir: string): boolean { + return !!dir.match(/^(@[a-z0-9-~][a-z0-9-._~]*__)?[a-z0-9-~][a-z0-9-._~]*$/); +} + +export function isValidConfig(config: CodeshiftConfig) { + return ( + hasValidTransforms(config.transforms) || hasValidPresets(config.presets) + ); +} + +export function isValidConfigAtPath(filePath: string) { + const config = getConfigFromPath(filePath); + + if ( + !hasValidTransforms(config.transforms) && + !hasValidPresets(config.presets) + ) { throw new Error( - `At least one transform should be specified for config at "${configPath}"`, + `At least one transform should be specified for config at "${filePath}"`, ); } - if (invalidSemverIds.length) { - throw new Error(`Invalid transform ids found for config at "${configPath}". + if (!hasValidTransforms(config.transforms)) { + throw new Error(`Invalid transform ids found for config at "${filePath}". Please make sure all transforms are identified by a valid semver version. ie 10.0.0`); } - if (invalidPresetIds.length) { - throw new Error(`Invalid preset ids found for config at "${configPath}". + if (!hasValidPresets(config.presets)) { + throw new Error(`Invalid preset ids found for config at "${filePath}". Please make sure all presets are kebab case and contain no spaces or special characters. ie sort-imports-by-scope`); } } @@ -60,4 +69,6 @@ export async function isValidPackageJson(path: string) { if (!packageJson.main) { throw new Error('No main entrypoint provided in package.json'); } + + return true; } diff --git a/scripts/validate.ts b/scripts/validate.ts index 57070ec04..0e1a2e33f 100644 --- a/scripts/validate.ts +++ b/scripts/validate.ts @@ -1,5 +1,5 @@ import fs, { lstatSync, existsSync } from 'fs-extra'; -import { isValidPackageName, isValidConfig } from '@codeshift/validator'; +import { isValidPackageName, isValidConfigAtPath } from '@codeshift/validator'; async function main(path: string) { const directories = await fs.readdir(path); @@ -13,7 +13,7 @@ async function main(path: string) { ); } - await isValidConfig(`${path}/${dir}`); + await isValidConfigAtPath(`${path}/${dir}`); const subDirectories = await fs.readdir(`${path}/${dir}`); subDirectories.forEach(async subDir => { diff --git a/yarn.lock b/yarn.lock index 0ccb6c00f..d785c964c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1722,6 +1722,11 @@ resolved "https://registry.npmjs.org/@types/lockfile/-/lockfile-1.0.2.tgz#3f77e84171a2b7e3198bd5717c7547a54393baf8" integrity sha512-jD5VbvhfMhaYN4M3qPJuhMVUg3Dfc4tvPvLEAXn6GXbs/ajDFtCQahX37GIE65ipTI3I+hEvNaXS3MYAn9Ce3Q== +"@types/lodash@^4.14.176": + version "4.14.176" + resolved "https://packages.atlassian.com/api/npm/npm-remote/@types/lodash/-/lodash-4.14.176.tgz#641150fc1cda36fbfa329de603bbb175d7ee20c0" + integrity sha1-ZBFQ/BzaNvv6Mp3mA7uxddfuIMA= + "@types/minimatch@*": version "3.0.5" resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" @@ -5009,10 +5014,10 @@ lodash.unescape@4.0.1: resolved "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c" integrity sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw= -lodash@4.x, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.7.0: +lodash@4.x, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" - resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + resolved "https://packages.atlassian.com/api/npm/npm-remote/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha1-Z5WRxWTDv/quhFTPCz3zcMPWkRw= loud-rejection@^1.0.0: version "1.6.0"