diff --git a/.changeset/pink-bananas-admire.md b/.changeset/pink-bananas-admire.md new file mode 100644 index 000000000..2a7ca4bcc --- /dev/null +++ b/.changeset/pink-bananas-admire.md @@ -0,0 +1,5 @@ +--- +'@hypermod/cli': minor +--- + +Adds the ability to fetch codemods from the hypermod app API. Also includes a large refactor of the source code to accomidate diff --git a/package.json b/package.json index d4b1d6b17..b8a0e92ae 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/inquirer": "^8.2.1", "@types/jest": "^29.0.0", "@types/jscodeshift": "^0.11.6", + "@types/node": "^16.11.0", "@types/prettier": "^2.0.0", "@types/tar": "^4.0.4", "@typescript-eslint/eslint-plugin": "^5.0.0", diff --git a/packages/cli/src/fetchers/app.ts b/packages/cli/src/fetchers/app.ts new file mode 100644 index 000000000..32917a2ae --- /dev/null +++ b/packages/cli/src/fetchers/app.ts @@ -0,0 +1,73 @@ +import fs from 'fs-extra'; +import ora from 'ora'; +import chalk from 'chalk'; +import path from 'path'; + +interface MarketPlaceEntry { + id: string; + slug: string; + description: string; + status: string; + tags: string[]; + type: 'OPTIMIZATION' | 'MIGRATION'; + transformId: string; + transform: Transform; +} + +interface Transform { + author: { image: string; name: string }; + id: string; + name: string; + sources: Source[]; +} + +interface Source { + id: string; + name: string; + code: string; +} + +function writeSourceFiles(slug: string, transform: Transform, dir: string) { + transform.sources.forEach(source => { + const filePath = path.join(dir, slug, source.name); + fs.outputFileSync(filePath, source.code); + }); +} + +export async function fetchHmPkg(slug: string, dir: string) { + const spinner = ora( + `${chalk.green('Attempting to download Hypermod transform:')} ${slug}`, + ).start(); + + let marketplaceEntry: MarketPlaceEntry; + + try { + // @ts-expect-error + marketplaceEntry = await fetch( + `https://www.hypermod.io/api/cli/transforms/${slug.replace('hm-', '')}`, + ).then((res: any) => { + if (res.status === 404) { + throw new Error(`Transform not found: ${slug}`); + } + + if (!res.ok) { + throw new Error(`Error fetching transform: ${res.statusText}`); + } + + return res.json(); + }); + + spinner.succeed( + `${chalk.green( + 'Found Hypermod transform:', + )} https://www.hypermod.io/explore/${slug}`, + ); + } catch (error) { + spinner.fail(`${chalk.red('Unable to fetch Hypermod transform:')} ${slug}`); + throw new Error(`Unable to locate Hypermod transform: ${slug}\n${error}`); + } + + writeSourceFiles(slug, marketplaceEntry.transform, dir); + + return marketplaceEntry; +} diff --git a/packages/cli/src/utils/fetch-package.ts b/packages/cli/src/fetchers/npm.ts similarity index 95% rename from packages/cli/src/utils/fetch-package.ts rename to packages/cli/src/fetchers/npm.ts index 0e304ba87..1b68933be 100644 --- a/packages/cli/src/utils/fetch-package.ts +++ b/packages/cli/src/fetchers/npm.ts @@ -9,9 +9,9 @@ import { } from '@hypermod/fetcher'; import { isValidConfig } from '@hypermod/validator'; -import { getHypermodPackageName } from './package-names'; +import { getHypermodPackageName } from '../utils/package-names'; -export async function fetchPackages( +export async function fetchNpmPkg( packageName: string, packageManager: ModuleLoader, ) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 869a3ad87..cb05650b8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -180,7 +180,6 @@ Examples: } console.error(chalk.red(error)); - console.log(error); process.exit(1); } })(); diff --git a/packages/cli/src/list.ts b/packages/cli/src/list.ts index b8ec5aebf..607bf22e4 100644 --- a/packages/cli/src/list.ts +++ b/packages/cli/src/list.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { PluginManager } from 'live-plugin-manager'; -import { fetchPackages } from './utils/fetch-package'; +import { fetchNpmPkg } from './fetchers/npm'; import { getHypermodPackageName } from './utils/package-names'; export default async function list(packages: string[]) { @@ -10,7 +10,7 @@ export default async function list(packages: string[]) { for (const packageName of packages) { try { - const { community, remote } = await fetchPackages( + const { community, remote } = await fetchNpmPkg( packageName, packageManager, ); diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index bd076c4e4..0ad3c7374 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -1,22 +1,17 @@ import path from 'path'; -import semver from 'semver'; import chalk from 'chalk'; -import findUp from 'find-up'; -import inquirer from 'inquirer'; import { PluginManager, PluginManagerOptions } from 'live-plugin-manager'; import * as core from '@hypermod/core'; -import { - type ModuleLoader as MdlLoader, - fetchConfigAtPath, -} from '@hypermod/fetcher'; +import { type ModuleLoader as MdlLoader } from '@hypermod/fetcher'; import { InvalidUserInputError } from './errors'; -import { fetchPackages } from './utils/fetch-package'; -import { mergeConfigs } from './utils/merge-configs'; -import { fetchConfigsForWorkspaces, getPackageJson } from './utils/file-system'; import ModuleLoader from './utils/module-loader'; -import { getConfigPrompt, getMultiConfigPrompt } from './prompt'; +import { resolveLocalTransforms } from './resolvers/local'; +import { resolveNpmTransforms } from './resolvers/npm'; +import { resolveAppTransforms } from './resolvers/app'; + +const CLI_DIR = path.join(__dirname, '..', 'node_modules'); export default async function main( paths: string[], @@ -29,7 +24,7 @@ export default async function main( } const pluginManagerConfig: Partial = { - pluginsPath: path.join(__dirname, '..', 'node_modules'), + pluginsPath: CLI_DIR, }; // If a registry is provided in the CLI flags, use it for the pluginManagers configuration. @@ -53,6 +48,10 @@ export default async function main( let transforms: string[] = []; + /** + * If no transforms are provided, attempt to find codemods in the local hypermod.config file + * or in the local package.json file. + */ if (!flags.transform && !flags.packages) { console.log( chalk.green( @@ -60,115 +59,11 @@ export default async function main( ), ); - /** - * Attempt to locate a root package.json with a workspaces config. - * If found, show a prompt with all available codemods - */ - const localPackageJson = await getPackageJson(); - - if (localPackageJson && localPackageJson.workspaces) { - const configs = await fetchConfigsForWorkspaces( - localPackageJson.workspaces, - ); - const answers = await inquirer.prompt([getMultiConfigPrompt(configs)]); - const selectedConfig = configs.find( - ({ filePath }) => answers.codemod.filePath === filePath, - ); - - if (!selectedConfig) { - throw new Error( - `Unable to locate config at: ${answers.codemod.filePath}`, - ); - } - - if ( - selectedConfig.config.transforms && - selectedConfig.config.transforms[answers.codemod.selection] - ) { - if (flags.sequence) { - Object.entries( - selectedConfig.config.transforms as Record, - ) - .filter(([key]) => - semver.satisfies(key, `>=${answers.codemod.selection}`), - ) - .forEach(([, path]) => transforms.push(path)); - } else { - transforms.push( - selectedConfig.config.transforms[answers.codemod.selection], - ); - } - } else if ( - selectedConfig.config.presets && - selectedConfig.config.presets[answers.codemod.selection] - ) { - transforms.push( - selectedConfig.config.presets[answers.codemod.selection], - ); - } - } else { - /** - * Otherwise, locate any config files in parent directories - */ - const configFilePath = await findUp([ - 'hypermod.config.js', - 'hypermod.config.mjs', - 'hypermod.config.cjs', - 'hypermod.config.ts', - 'hypermod.config.tsx', - 'src/hypermod.config.js', - 'src/hypermod.config.mjs', - 'src/hypermod.config.cjs', - 'src/hypermod.config.ts', - 'src/hypermod.config.tsx', - 'codemods/hypermod.config.js', - 'codemods/hypermod.config.mjs', - 'codemods/hypermod.config.cjs', - 'codemods/hypermod.config.ts', - 'codemods/hypermod.config.tsx', - 'codeshift.config.js', - 'codeshift.config.mjs', - 'codeshift.config.cjs', - 'codeshift.config.ts', - 'codeshift.config.tsx', - 'src/codeshift.config.js', - 'src/codeshift.config.mjs', - 'src/codeshift.config.cjs', - 'src/codeshift.config.ts', - 'src/codeshift.config.tsx', - 'codemods/codeshift.config.js', - 'codemods/codeshift.config.mjs', - 'codemods/codeshift.config.cjs', - 'codemods/codeshift.config.ts', - 'codemods/codeshift.config.tsx', - ]); - - if (!configFilePath) { - throw new InvalidUserInputError( - 'No transform provided, please specify a transform with either the --transform or --packages flags', - ); - } - - console.log( - chalk.green('Found local hypermod.config file at:'), - configFilePath, - ); - - const config = await fetchConfigAtPath(configFilePath); - const answers = await inquirer.prompt([getConfigPrompt(config)]); - - if (config.transforms && config.transforms[answers.codemod]) { - Object.entries(config.transforms) - .filter(([key]) => semver.satisfies(key, `>=${answers.codemod}`)) - .forEach(([, codemod]) => - transforms.push(`${configFilePath}@${codemod}`), - ); - } else if (config.presets && config.presets[answers.codemod]) { - transforms.push(`${configFilePath}#${answers.codemod}`); - } - } + const localTransforms = await resolveLocalTransforms(flags); + transforms.push(...localTransforms); } + // If a direct path to a transform is provided, use it if (flags.transform) { if (flags.transform.includes(',')) { flags.transform.split(',').forEach(t => transforms.push(t.trim())); @@ -177,91 +72,32 @@ export default async function main( } } + // If a package name is provided, fetch the community and remote configs + // and merge them to get the transforms if (flags.packages) { - const pkgs = flags.packages.split(',').filter(pkg => !!pkg); + const pkgs = flags.packages!.split(',').filter(pkg => !!pkg); for (const pkg of pkgs) { - const shouldPrependAtSymbol = pkg.startsWith('@') ? '@' : ''; - const pkgName = - shouldPrependAtSymbol + pkg.split(/[@#]/).filter(str => !!str)[0]; - - const rawTransformIds = pkg.split(/(?=[@#])/).filter(str => !!str); - rawTransformIds.shift(); - - const transformIds = rawTransformIds - .filter(id => id.startsWith('@')) - .map(id => id.substring(1)) - .sort((idA, idB) => { - if (semver.lt(idA, idB)) return -1; - if (semver.gt(idA, idB)) return 1; - return 0; - }); - - const presetIds = rawTransformIds - .filter(id => id.startsWith('#')) - .map(id => id.substring(1)); + /** + * If the package name starts with "hm-", it is a Hypermod transform. + * We need to fetch the transform from the Hypermod API and add it to the transforms array. + */ + if (pkg.startsWith('hm-')) { + const hmTransform = await resolveAppTransforms(pkg, CLI_DIR); + transforms.push(hmTransform); + continue; + } - const { community, remote } = await fetchPackages( - pkgName, + /** + * We need to fetch the transform from the npm registry and add it to the transforms array. + */ + const npmTransforms = await resolveNpmTransforms( + flags, + pkg, packageManager, ); - const config = mergeConfigs(community, remote); - - // Validate transforms/presets - transformIds.forEach(id => { - if (!semver.valid(semver.coerce(id.substring(1)))) { - throw new InvalidUserInputError( - `Invalid version provided to the --packages flag. Unable to resolve version "${id}" for package "${pkgName}". Please try: "[scope]/[package]@[version]" for example @mylib/mypackage@10.0.0`, - ); - } - - if (!config.transforms || !config.transforms[id]) { - throw new InvalidUserInputError( - `Invalid version provided to the --packages flag. Unable to resolve version "${id}" for package "${pkgName}"`, - ); - } - }); - - presetIds.forEach(id => { - if (!config.presets || !config.presets[id]) { - throw new InvalidUserInputError( - `Invalid preset provided to the --packages flag. Unable to resolve preset "${id}" for package "${pkgName}"`, - ); - } - }); - - if (presetIds.length === 0 && transformIds.length === 0) { - const res: { codemod: string } = await inquirer.prompt([ - getConfigPrompt(config), - ]); - - if (semver.valid(semver.coerce(res.codemod))) { - transformIds.push(res.codemod); - } else { - presetIds.push(res.codemod); - } - } - - // Get transform file paths - if (config.transforms) { - if (flags.sequence) { - Object.entries(config.transforms) - .filter(([key]) => semver.satisfies(key, `>=${transformIds[0]}`)) - .forEach(([, path]) => transforms.push(path)); - } else { - Object.entries(config.transforms) - .filter(([id]) => transformIds.includes(id)) - .forEach(([, path]) => transforms.push(path)); - } - } - - // Get preset file paths - if (config.presets) { - Object.entries(config.presets) - .filter(([id]) => presetIds.includes(id)) - .forEach(([, path]) => transforms.push(path)); - } + transforms.push(...npmTransforms); } } diff --git a/packages/cli/src/resolvers/app.ts b/packages/cli/src/resolvers/app.ts new file mode 100644 index 000000000..2afeece0a --- /dev/null +++ b/packages/cli/src/resolvers/app.ts @@ -0,0 +1,22 @@ +import path from 'path'; + +import { fetchHmPkg } from '../fetchers/app'; + +export async function resolveAppTransforms(pkg: string, dir: string) { + const transformMeta = await fetchHmPkg(pkg, dir); + + // find entry point + const entryPoint = transformMeta.transform.sources.find( + source => + source.name.includes('transform.ts') || + source.name.includes('transform.js'), + ); + + if (!entryPoint) { + throw new Error( + `Unable to locate transform entry point in package: ${pkg}`, + ); + } + + return path.join(dir, pkg, entryPoint.name); +} diff --git a/packages/cli/src/resolvers/local.ts b/packages/cli/src/resolvers/local.ts new file mode 100644 index 000000000..2bce58c53 --- /dev/null +++ b/packages/cli/src/resolvers/local.ts @@ -0,0 +1,128 @@ +import chalk from 'chalk'; +import findUp from 'find-up'; +import inquirer from 'inquirer'; +import semver from 'semver'; + +import * as core from '@hypermod/core'; +import { fetchConfigAtPath } from '@hypermod/fetcher'; + +import { InvalidUserInputError } from '../errors'; +import { + fetchConfigsForWorkspaces, + getPackageJson, +} from '../utils/file-system'; +import { getConfigPrompt, getMultiConfigPrompt } from '../prompt'; + +/** + * Resolves local transforms from the local file system hypermod.config file or package.json file. + */ +export async function resolveLocalTransforms(flags: Partial) { + const transforms: string[] = []; + /** + * Attempt to locate a root package.json with a workspaces config. + * If found, show a prompt with all available codemods + */ + const localPackageJson = await getPackageJson(); + + if (localPackageJson && localPackageJson.workspaces) { + const configs = await fetchConfigsForWorkspaces( + localPackageJson.workspaces, + ); + const answers = await inquirer.prompt([getMultiConfigPrompt(configs)]); + const selectedConfig = configs.find( + ({ filePath }) => answers.codemod.filePath === filePath, + ); + + if (!selectedConfig) { + throw new Error( + `Unable to locate config at: ${answers.codemod.filePath}`, + ); + } + + if ( + selectedConfig.config.transforms && + selectedConfig.config.transforms[answers.codemod.selection] + ) { + if (flags.sequence) { + Object.entries( + selectedConfig.config.transforms as Record, + ) + .filter(([key]) => + semver.satisfies(key, `>=${answers.codemod.selection}`), + ) + .forEach(([, path]) => transforms.push(path)); + } else { + transforms.push( + selectedConfig.config.transforms[answers.codemod.selection], + ); + } + } else if ( + selectedConfig.config.presets && + selectedConfig.config.presets[answers.codemod.selection] + ) { + transforms.push(selectedConfig.config.presets[answers.codemod.selection]); + } + } else { + /** + * Otherwise, locate any config files in parent directories + */ + const configFilePath = await findUp([ + 'hypermod.config.js', + 'hypermod.config.mjs', + 'hypermod.config.cjs', + 'hypermod.config.ts', + 'hypermod.config.tsx', + 'src/hypermod.config.js', + 'src/hypermod.config.mjs', + 'src/hypermod.config.cjs', + 'src/hypermod.config.ts', + 'src/hypermod.config.tsx', + 'codemods/hypermod.config.js', + 'codemods/hypermod.config.mjs', + 'codemods/hypermod.config.cjs', + 'codemods/hypermod.config.ts', + 'codemods/hypermod.config.tsx', + 'codeshift.config.js', + 'codeshift.config.mjs', + 'codeshift.config.cjs', + 'codeshift.config.ts', + 'codeshift.config.tsx', + 'src/codeshift.config.js', + 'src/codeshift.config.mjs', + 'src/codeshift.config.cjs', + 'src/codeshift.config.ts', + 'src/codeshift.config.tsx', + 'codemods/codeshift.config.js', + 'codemods/codeshift.config.mjs', + 'codemods/codeshift.config.cjs', + 'codemods/codeshift.config.ts', + 'codemods/codeshift.config.tsx', + ]); + + if (!configFilePath) { + throw new InvalidUserInputError( + 'No transform provided, please specify a transform with either the --transform or --packages flags', + ); + } + + console.log( + chalk.green('Found local hypermod.config file at:'), + configFilePath, + ); + + const config = await fetchConfigAtPath(configFilePath); + const answers = await inquirer.prompt([getConfigPrompt(config)]); + + if (config.transforms && config.transforms[answers.codemod]) { + Object.entries(config.transforms) + .filter(([key]) => semver.satisfies(key, `>=${answers.codemod}`)) + .forEach(([, codemod]) => + transforms.push(`${configFilePath}@${codemod}`), + ); + } else if (config.presets && config.presets[answers.codemod]) { + transforms.push(`${configFilePath}#${answers.codemod}`); + } + } + + return transforms; +} diff --git a/packages/cli/src/resolvers/npm.ts b/packages/cli/src/resolvers/npm.ts new file mode 100644 index 000000000..399f6d7b7 --- /dev/null +++ b/packages/cli/src/resolvers/npm.ts @@ -0,0 +1,104 @@ +import inquirer from 'inquirer'; +import semver from 'semver'; + +import * as core from '@hypermod/core'; + +import { InvalidUserInputError } from '../errors'; +import { getConfigPrompt } from '../prompt'; +import { fetchNpmPkg } from '../fetchers/npm'; +import { mergeConfigs } from '../utils/merge-configs'; +import { ModuleLoader } from '@hypermod/fetcher'; + +/** + * Resolves transforms from the npm registry. + * If no transforms are provided, show a prompt with all available codemods + * for the provided package. + */ +export async function resolveNpmTransforms( + flags: Partial, + pkg: string, + packageManager: ModuleLoader, +) { + const transforms: string[] = []; + + const shouldPrependAtSymbol = pkg.startsWith('@') ? '@' : ''; + const pkgName = + shouldPrependAtSymbol + pkg.split(/[@#]/).filter(str => !!str)[0]; + + const rawTransformIds = pkg.split(/(?=[@#])/).filter(str => !!str); + rawTransformIds.shift(); + + const transformIds = rawTransformIds + .filter(id => id.startsWith('@')) + .map(id => id.substring(1)) + .sort((idA, idB) => { + if (semver.lt(idA, idB)) return -1; + if (semver.gt(idA, idB)) return 1; + return 0; + }); + + const presetIds = rawTransformIds + .filter(id => id.startsWith('#')) + .map(id => id.substring(1)); + + const { community, remote } = await fetchNpmPkg(pkgName, packageManager); + + const config = mergeConfigs(community, remote); + + // Validate transforms/presets + transformIds.forEach(id => { + if (!semver.valid(semver.coerce(id.substring(1)))) { + throw new InvalidUserInputError( + `Invalid version provided to the --packages flag. Unable to resolve version "${id}" for package "${pkgName}". Please try: "[scope]/[package]@[version]" for example @mylib/mypackage@10.0.0`, + ); + } + + if (!config.transforms || !config.transforms[id]) { + throw new InvalidUserInputError( + `Invalid version provided to the --packages flag. Unable to resolve version "${id}" for package "${pkgName}"`, + ); + } + }); + + presetIds.forEach(id => { + if (!config.presets || !config.presets[id]) { + throw new InvalidUserInputError( + `Invalid preset provided to the --packages flag. Unable to resolve preset "${id}" for package "${pkgName}"`, + ); + } + }); + + if (presetIds.length === 0 && transformIds.length === 0) { + const res: { codemod: string } = await inquirer.prompt([ + getConfigPrompt(config), + ]); + + if (semver.valid(semver.coerce(res.codemod))) { + transformIds.push(res.codemod); + } else { + presetIds.push(res.codemod); + } + } + + // Get transform file paths + if (config.transforms) { + if (flags.sequence) { + Object.entries(config.transforms) + .filter(([key]) => semver.satisfies(key, `>=${transformIds[0]}`)) + .forEach(([, path]) => transforms.push(path)); + } else { + Object.entries(config.transforms) + .filter(([id]) => transformIds.includes(id)) + .forEach(([, path]) => transforms.push(path)); + } + } + + // Get preset file paths + if (config.presets) { + Object.entries(config.presets) + .filter(([id]) => presetIds.includes(id)) + .forEach(([, path]) => transforms.push(path)); + } + + return transforms; +} diff --git a/tsconfig.json b/tsconfig.json index f31e7316d..708f36d64 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,18 +7,8 @@ "module": "CommonJS", "resolveJsonModule": true, "esModuleInterop": true, - "lib": [ - "es5", - "scripthost", - "es2015.core", - "es2015.collection", - "es2015.symbol", - "es2015.iterable", - "es2015.promise", - "es2016", - "es2017" - ] + "lib": ["ESNext", "scripthost"] }, "include": ["packages/**/*", "community/**/*", "scripts"], - "exclude": ["./node_modules", "./plugin_packages"] + "exclude": ["./plugin_packages"] }