From dd84b9d64fb98746a230cd24233ff50a562c39c9 Mon Sep 17 00:00:00 2001 From: 简律纯 Date: Fri, 28 Apr 2023 01:36:44 +0800 Subject: --- packages/turbo-codemod/src/cli.ts | 73 +++++++ packages/turbo-codemod/src/commands/index.ts | 11 ++ .../turbo-codemod/src/commands/migrate/index.ts | 215 +++++++++++++++++++++ .../commands/migrate/steps/getCurrentVersion.ts | 45 +++++ .../src/commands/migrate/steps/getLatestVersion.ts | 31 +++ .../migrate/steps/getTransformsForMigration.ts | 25 +++ .../migrate/steps/getTurboUpgradeCommand.ts | 182 +++++++++++++++++ .../turbo-codemod/src/commands/migrate/types.ts | 9 + .../turbo-codemod/src/commands/migrate/utils.ts | 16 ++ .../turbo-codemod/src/commands/transform/index.ts | 101 ++++++++++ .../turbo-codemod/src/commands/transform/types.ts | 7 + packages/turbo-codemod/src/runner/FileTransform.ts | 94 +++++++++ packages/turbo-codemod/src/runner/Runner.ts | 132 +++++++++++++ packages/turbo-codemod/src/runner/index.ts | 3 + packages/turbo-codemod/src/runner/types.ts | 40 ++++ packages/turbo-codemod/src/transforms/README.md | 36 ++++ .../src/transforms/add-package-manager.ts | 75 +++++++ .../src/transforms/create-turbo-config.ts | 70 +++++++ .../src/transforms/migrate-env-var-dependencies.ts | 181 +++++++++++++++++ .../src/transforms/set-default-outputs.ts | 97 ++++++++++ packages/turbo-codemod/src/types.ts | 24 +++ packages/turbo-codemod/src/utils/checkGitStatus.ts | 40 ++++ packages/turbo-codemod/src/utils/directoryInfo.ts | 10 + .../turbo-codemod/src/utils/getPackageManager.ts | 42 ++++ .../src/utils/getPackageManagerVersion.ts | 16 ++ .../src/utils/getTransformerHelpers.ts | 23 +++ .../turbo-codemod/src/utils/loadTransformers.ts | 27 +++ packages/turbo-codemod/src/utils/logger.ts | 47 +++++ packages/turbo-codemod/src/utils/looksLikeRepo.ts | 12 ++ packages/turbo-codemod/src/utils/notifyUpdate.ts | 35 ++++ 30 files changed, 1719 insertions(+) create mode 100644 packages/turbo-codemod/src/cli.ts create mode 100644 packages/turbo-codemod/src/commands/index.ts create mode 100644 packages/turbo-codemod/src/commands/migrate/index.ts create mode 100644 packages/turbo-codemod/src/commands/migrate/steps/getCurrentVersion.ts create mode 100644 packages/turbo-codemod/src/commands/migrate/steps/getLatestVersion.ts create mode 100644 packages/turbo-codemod/src/commands/migrate/steps/getTransformsForMigration.ts create mode 100644 packages/turbo-codemod/src/commands/migrate/steps/getTurboUpgradeCommand.ts create mode 100644 packages/turbo-codemod/src/commands/migrate/types.ts create mode 100644 packages/turbo-codemod/src/commands/migrate/utils.ts create mode 100644 packages/turbo-codemod/src/commands/transform/index.ts create mode 100644 packages/turbo-codemod/src/commands/transform/types.ts create mode 100644 packages/turbo-codemod/src/runner/FileTransform.ts create mode 100644 packages/turbo-codemod/src/runner/Runner.ts create mode 100644 packages/turbo-codemod/src/runner/index.ts create mode 100644 packages/turbo-codemod/src/runner/types.ts create mode 100644 packages/turbo-codemod/src/transforms/README.md create mode 100644 packages/turbo-codemod/src/transforms/add-package-manager.ts create mode 100644 packages/turbo-codemod/src/transforms/create-turbo-config.ts create mode 100644 packages/turbo-codemod/src/transforms/migrate-env-var-dependencies.ts create mode 100644 packages/turbo-codemod/src/transforms/set-default-outputs.ts create mode 100644 packages/turbo-codemod/src/types.ts create mode 100644 packages/turbo-codemod/src/utils/checkGitStatus.ts create mode 100644 packages/turbo-codemod/src/utils/directoryInfo.ts create mode 100644 packages/turbo-codemod/src/utils/getPackageManager.ts create mode 100644 packages/turbo-codemod/src/utils/getPackageManagerVersion.ts create mode 100644 packages/turbo-codemod/src/utils/getTransformerHelpers.ts create mode 100644 packages/turbo-codemod/src/utils/loadTransformers.ts create mode 100644 packages/turbo-codemod/src/utils/logger.ts create mode 100644 packages/turbo-codemod/src/utils/looksLikeRepo.ts create mode 100644 packages/turbo-codemod/src/utils/notifyUpdate.ts (limited to 'packages/turbo-codemod/src') diff --git a/packages/turbo-codemod/src/cli.ts b/packages/turbo-codemod/src/cli.ts new file mode 100644 index 0000000..451816f --- /dev/null +++ b/packages/turbo-codemod/src/cli.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +import chalk from "chalk"; +import { Command } from "commander"; + +import { transform, migrate } from "./commands"; +import notifyUpdate from "./utils/notifyUpdate"; +import cliPkg from "../package.json"; + +const codemodCli = new Command(); + +codemodCli + .name("@turbo/codemod") + .description( + "Codemod transformations to help upgrade your Turborepo codebase when a feature is deprecated." + ) + .version(cliPkg.version, "-v, --version", "output the current version"); + +// migrate +codemodCli + .command("migrate") + .aliases(["update", "upgrade"]) + .description("Migrate a project to the latest version of Turborepo") + .argument("[path]", "Directory where the transforms should be applied") + .option( + "--from ", + "Specify the version to migrate from (default: current version)" + ) + .option( + "--to ", + "Specify the version to migrate to (default: latest)" + ) + .option("--install", "Install new version of turbo after migration", true) + .option( + "--force", + "Bypass Git safety checks and forcibly run codemods", + false + ) + .option("--dry", "Dry run (no changes are made to files)", false) + .option("--print", "Print transformed files to your terminal", false) + .action(migrate); + +// transform +codemodCli + .command("transform", { isDefault: true }) + .description("Apply a single code transformation to a project") + .argument("[transform]", "The transformer to run") + .argument("[path]", "Directory where the transforms should be applied") + .option( + "--force", + "Bypass Git safety checks and forcibly run codemods", + false + ) + .option("--list", "List all available transforms", false) + .option("--dry", "Dry run (no changes are made to files)", false) + .option("--print", "Print transformed files to your terminal", false) + .action(transform); + +codemodCli + .parseAsync() + .then(notifyUpdate) + .catch(async (reason) => { + console.log(); + if (reason.command) { + console.log(` ${chalk.cyan(reason.command)} has failed.`); + } else { + console.log(chalk.red("Unexpected error. Please report it as a bug:")); + console.log(reason); + } + console.log(); + await notifyUpdate(); + process.exit(1); + }); diff --git a/packages/turbo-codemod/src/commands/index.ts b/packages/turbo-codemod/src/commands/index.ts new file mode 100644 index 0000000..a7aeee6 --- /dev/null +++ b/packages/turbo-codemod/src/commands/index.ts @@ -0,0 +1,11 @@ +export { default as migrate } from "./migrate"; +export { default as transform } from "./transform"; + +export type { + TransformCommandArgument, + TransformCommandOptions, +} from "./transform/types"; +export type { + MigrateCommandArgument, + MigrateCommandOptions, +} from "./migrate/types"; diff --git a/packages/turbo-codemod/src/commands/migrate/index.ts b/packages/turbo-codemod/src/commands/migrate/index.ts new file mode 100644 index 0000000..c4c6d02 --- /dev/null +++ b/packages/turbo-codemod/src/commands/migrate/index.ts @@ -0,0 +1,215 @@ +import chalk from "chalk"; +import os from "os"; +import inquirer from "inquirer"; +import { execSync } from "child_process"; + +import getCurrentVersion from "./steps/getCurrentVersion"; +import getLatestVersion from "./steps/getLatestVersion"; +import getCodemodsForMigration from "./steps/getTransformsForMigration"; +import checkGitStatus from "../../utils/checkGitStatus"; +import directoryInfo from "../../utils/directoryInfo"; +import getTurboUpgradeCommand from "./steps/getTurboUpgradeCommand"; +import Runner from "../../runner/Runner"; +import type { MigrateCommandArgument, MigrateCommandOptions } from "./types"; +import looksLikeRepo from "../../utils/looksLikeRepo"; + +function endMigration({ + message, + success, +}: { + message?: string; + success: boolean; +}) { + if (success) { + console.log(chalk.bold(chalk.green("Migration completed"))); + if (message) { + console.log(message); + } + return process.exit(0); + } + + console.log(chalk.bold(chalk.red("Migration failed"))); + if (message) { + console.log(message); + } + return process.exit(1); +} + +/** +Migration is done in 4 steps: + -- gather information + 1. find the version (x) of turbo to migrate from (if not specified) + 2. find the version (y) of turbo to migrate to (if not specified) + 3. determine which codemods need to be run to move from version x to version y + -- action + 4. execute the codemods (serially, and in order) + 5. update the turbo version (optionally) +**/ +export default async function migrate( + directory: MigrateCommandArgument, + options: MigrateCommandOptions +) { + // check git status + if (!options.dry) { + checkGitStatus({ directory, force: options.force }); + } + + const answers = await inquirer.prompt<{ + directoryInput?: string; + }>([ + { + type: "input", + name: "directoryInput", + message: "Where is the root of the repo to migrate?", + when: !directory, + default: ".", + validate: (directory: string) => { + const { exists, absolute } = directoryInfo({ directory }); + if (exists) { + return true; + } else { + return `Directory ${chalk.dim(`(${absolute})`)} does not exist`; + } + }, + filter: (directory: string) => directory.trim(), + }, + ]); + + const { directoryInput: selectedDirectory = directory as string } = answers; + const { exists, absolute: root } = directoryInfo({ + directory: selectedDirectory, + }); + if (!exists) { + return endMigration({ + success: false, + message: `Directory ${chalk.dim(`(${root})`)} does not exist`, + }); + } + + if (!looksLikeRepo({ directory: root })) { + return endMigration({ + success: false, + message: `Directory (${chalk.dim( + root + )}) does not appear to be a repository`, + }); + } + + // step 1 + const fromVersion = getCurrentVersion(selectedDirectory, options); + if (!fromVersion) { + return endMigration({ + success: false, + message: `Unable to infer the version of turbo being used by ${directory}`, + }); + } + + // step 2 + let toVersion = options.to; + try { + toVersion = await getLatestVersion(options); + } catch (err) { + let message = "UNKNOWN_ERROR"; + if (err instanceof Error) { + message = err.message; + } + return endMigration({ + success: false, + message, + }); + } + + if (!toVersion) { + return endMigration({ + success: false, + message: `Unable to fetch the latest version of turbo`, + }); + } + + if (fromVersion === toVersion) { + return endMigration({ + success: true, + message: `Nothing to do, current version (${chalk.bold( + fromVersion + )}) is the same as the requested version (${chalk.bold(toVersion)})`, + }); + } + + // step 3 + const codemods = getCodemodsForMigration({ fromVersion, toVersion }); + if (codemods.length === 0) { + console.log( + `No codemods required to migrate from ${fromVersion} to ${toVersion}`, + os.EOL + ); + } + + // step 4 + console.log( + `Upgrading turbo from ${chalk.bold(fromVersion)} to ${chalk.bold( + toVersion + )} (${ + codemods.length === 0 + ? "no codemods required" + : `${codemods.length} required codemod${ + codemods.length === 1 ? "" : "s" + }` + })`, + os.EOL + ); + const results = codemods.map((codemod, idx) => { + console.log( + `(${idx + 1}/${codemods.length}) ${chalk.bold( + `Running ${codemod.value}` + )}` + ); + + const result = codemod.transformer({ root: selectedDirectory, options }); + Runner.logResults(result); + return result; + }); + + const hasTransformError = results.some( + (result) => + result.fatalError || + Object.keys(result.changes).some((key) => result.changes[key].error) + ); + + if (hasTransformError) { + return endMigration({ + success: false, + message: `Could not complete migration due to codemod errors. Please fix the errors and try again.`, + }); + } + + // step 5 + const upgradeCommand = getTurboUpgradeCommand({ + directory: selectedDirectory, + to: options.to, + }); + + if (!upgradeCommand) { + return endMigration({ + success: false, + message: "Unable to determine upgrade command", + }); + } + + if (options.install) { + if (options.dry) { + console.log( + `Upgrading turbo with ${chalk.bold(upgradeCommand)} ${chalk.dim( + "(dry run)" + )}`, + os.EOL + ); + } else { + console.log(`Upgrading turbo with ${chalk.bold(upgradeCommand)}`, os.EOL); + execSync(upgradeCommand, { cwd: selectedDirectory }); + } + } else { + console.log(`Upgrade turbo with ${chalk.bold(upgradeCommand)}`, os.EOL); + } + + endMigration({ success: true }); +} diff --git a/packages/turbo-codemod/src/commands/migrate/steps/getCurrentVersion.ts b/packages/turbo-codemod/src/commands/migrate/steps/getCurrentVersion.ts new file mode 100644 index 0000000..3644f8b --- /dev/null +++ b/packages/turbo-codemod/src/commands/migrate/steps/getCurrentVersion.ts @@ -0,0 +1,45 @@ +import path from "path"; +import { existsSync } from "fs-extra"; + +import getPackageManager from "../../../utils/getPackageManager"; +import { exec } from "../utils"; +import type { MigrateCommandOptions } from "../types"; + +function getCurrentVersion( + directory: string, + opts: MigrateCommandOptions +): string | undefined { + const { from } = opts; + if (from) { + return from; + } + + // try global first + const turboVersionFromGlobal = exec(`turbo --version`, { cwd: directory }); + + if (turboVersionFromGlobal) { + return turboVersionFromGlobal; + } + + // try to use the package manager to find the version + const packageManager = getPackageManager({ directory }); + if (packageManager) { + if (packageManager === "yarn") { + return exec(`yarn turbo --version`, { cwd: directory }); + } + if (packageManager === "pnpm") { + return exec(`pnpm turbo --version`, { cwd: directory }); + } else { + // this doesn't work for npm, so manually build the binary path + const turboBin = path.join(directory, "node_modules", ".bin", "turbo"); + if (existsSync(turboBin)) { + return exec(`${turboBin} --version`, { cwd: directory }); + } + } + } + + // unable to determine local version, + return undefined; +} + +export default getCurrentVersion; diff --git a/packages/turbo-codemod/src/commands/migrate/steps/getLatestVersion.ts b/packages/turbo-codemod/src/commands/migrate/steps/getLatestVersion.ts new file mode 100644 index 0000000..a6ab7e6 --- /dev/null +++ b/packages/turbo-codemod/src/commands/migrate/steps/getLatestVersion.ts @@ -0,0 +1,31 @@ +import axios from "axios"; + +import type { MigrateCommandOptions } from "../types"; + +const REGISTRY = "https://registry.npmjs.org"; + +async function getPackageDetails({ packageName }: { packageName: string }) { + try { + const result = await axios.get(`${REGISTRY}/${packageName}`); + return result.data; + } catch (err) { + throw new Error(`Unable to fetch the latest version of ${packageName}`); + } +} + +export default async function getLatestVersion({ + to, +}: MigrateCommandOptions): Promise { + const packageDetails = await getPackageDetails({ packageName: "turbo" }); + const { "dist-tags": tags, versions } = packageDetails; + + if (to) { + if (tags[to] || versions[to]) { + return to; + } else { + throw new Error(`turbo@${to} does not exist`); + } + } + + return tags.latest as string; +} diff --git a/packages/turbo-codemod/src/commands/migrate/steps/getTransformsForMigration.ts b/packages/turbo-codemod/src/commands/migrate/steps/getTransformsForMigration.ts new file mode 100644 index 0000000..2224c06 --- /dev/null +++ b/packages/turbo-codemod/src/commands/migrate/steps/getTransformsForMigration.ts @@ -0,0 +1,25 @@ +import { gt, lte } from "semver"; + +import loadTransformers from "../../../utils/loadTransformers"; +import type { Transformer } from "../../../types"; + +/** + Returns all transformers introduced after fromVersion, but before or equal to toVersion +**/ +function getTransformsForMigration({ + fromVersion, + toVersion, +}: { + fromVersion: string; + toVersion: string; +}): Array { + const transforms = loadTransformers(); + return transforms.filter((transformer) => { + return ( + gt(transformer.introducedIn, fromVersion) && + lte(transformer.introducedIn, toVersion) + ); + }); +} + +export default getTransformsForMigration; diff --git a/packages/turbo-codemod/src/commands/migrate/steps/getTurboUpgradeCommand.ts b/packages/turbo-codemod/src/commands/migrate/steps/getTurboUpgradeCommand.ts new file mode 100644 index 0000000..8fd5972 --- /dev/null +++ b/packages/turbo-codemod/src/commands/migrate/steps/getTurboUpgradeCommand.ts @@ -0,0 +1,182 @@ +import os from "os"; +import path from "path"; +import fs from "fs-extra"; +import { gte } from "semver"; + +import { exec } from "../utils"; +import getPackageManager, { + PackageManager, +} from "../../../utils/getPackageManager"; +import getPackageManagerVersion from "../../../utils/getPackageManagerVersion"; + +type InstallType = "dependencies" | "devDependencies"; + +function getGlobalBinaryPaths(): Record { + return { + // we run these from a tmpdir to avoid corepack interference + yarn: exec(`yarn global bin`, { cwd: os.tmpdir() }), + npm: exec(`npm bin --global`, { cwd: os.tmpdir() }), + pnpm: exec(`pnpm bin --global`, { cwd: os.tmpdir() }), + }; +} + +function getGlobalUpgradeCommand( + packageManager: PackageManager, + to: string = "latest" +) { + switch (packageManager) { + case "yarn": + return `yarn global add turbo@${to}`; + case "npm": + return `npm install turbo@${to} --global`; + case "pnpm": + return `pnpm install turbo@${to} --global`; + } +} + +function getLocalUpgradeCommand({ + packageManager, + packageManagerVersion, + installType, + isUsingWorkspaces, + to = "latest", +}: { + packageManager: PackageManager; + packageManagerVersion: string; + installType: InstallType; + isUsingWorkspaces?: boolean; + to?: string; +}) { + const renderCommand = ( + command: Array + ): string => command.filter(Boolean).join(" "); + switch (packageManager) { + // yarn command differs depending on the version + case "yarn": + // yarn 2.x and 3.x (berry) + if (gte(packageManagerVersion, "2.0.0")) { + return renderCommand([ + "yarn", + "add", + `turbo@${to}`, + installType === "devDependencies" && "--dev", + ]); + // yarn 1.x + } else { + return renderCommand([ + "yarn", + "add", + `turbo@${to}`, + installType === "devDependencies" && "--dev", + isUsingWorkspaces && "-W", + ]); + } + case "npm": + return renderCommand([ + "npm", + "install", + `turbo@${to}`, + installType === "devDependencies" && "--save-dev", + ]); + case "pnpm": + return renderCommand([ + "pnpm", + "install", + `turbo@${to}`, + installType === "devDependencies" && "--save-dev", + isUsingWorkspaces && "-w", + ]); + } +} + +function getInstallType({ directory }: { directory: string }): { + installType?: InstallType; + isUsingWorkspaces?: boolean; +} { + // read package.json to make sure we have a reference to turbo + const packageJsonPath = path.join(directory, "package.json"); + const pnpmWorkspaceConfig = path.join(directory, "pnpm-workspace.yaml"); + const isPnpmWorkspaces = fs.existsSync(pnpmWorkspaceConfig); + + if (!fs.existsSync(packageJsonPath)) { + console.error(`Unable to find package.json at ${packageJsonPath}`); + return { installType: undefined, isUsingWorkspaces: undefined }; + } + + const packageJson = fs.readJsonSync(packageJsonPath); + const isDevDependency = + packageJson.devDependencies && "turbo" in packageJson.devDependencies; + const isDependency = + packageJson.dependencies && "turbo" in packageJson.dependencies; + let isUsingWorkspaces = "workspaces" in packageJson || isPnpmWorkspaces; + + if (isDependency || isDevDependency) { + return { + installType: isDependency ? "dependencies" : "devDependencies", + isUsingWorkspaces, + }; + } + + return { + installType: undefined, + isUsingWorkspaces, + }; +} + +/** + Finding the correct command to upgrade depends on two things: + 1. The package manager + 2. The install method (local or global) + + We try global first to let turbo handle the inference, then we try local. +**/ +export default function getTurboUpgradeCommand({ + directory, + to, +}: { + directory: string; + to?: string; +}) { + const turboBinaryPathFromGlobal = exec(`turbo bin`, { + cwd: directory, + stdio: "pipe", + }); + const packageManagerGlobalBinaryPaths = getGlobalBinaryPaths(); + + const globalPackageManager = Object.keys( + packageManagerGlobalBinaryPaths + ).find((packageManager) => { + const packageManagerBinPath = + packageManagerGlobalBinaryPaths[packageManager as PackageManager]; + if (packageManagerBinPath && turboBinaryPathFromGlobal) { + return turboBinaryPathFromGlobal.includes(packageManagerBinPath); + } + + return false; + }) as PackageManager; + + if (turboBinaryPathFromGlobal && globalPackageManager) { + // figure which package manager we need to upgrade + return getGlobalUpgradeCommand(globalPackageManager, to); + } else { + const packageManager = getPackageManager({ directory }); + // we didn't find a global install, so we'll try to find a local one + const { installType, isUsingWorkspaces } = getInstallType({ directory }); + if (packageManager && installType) { + const packageManagerVersion = getPackageManagerVersion( + packageManager, + directory + ); + + return getLocalUpgradeCommand({ + packageManager, + packageManagerVersion, + installType, + isUsingWorkspaces, + to, + }); + } + } + + return undefined; +} diff --git a/packages/turbo-codemod/src/commands/migrate/types.ts b/packages/turbo-codemod/src/commands/migrate/types.ts new file mode 100644 index 0000000..ae90965 --- /dev/null +++ b/packages/turbo-codemod/src/commands/migrate/types.ts @@ -0,0 +1,9 @@ +import { TransformerOptions } from "../../types"; + +export type MigrateCommandArgument = "string" | undefined; + +export interface MigrateCommandOptions extends TransformerOptions { + from?: string; + to?: string; + install: boolean; +} diff --git a/packages/turbo-codemod/src/commands/migrate/utils.ts b/packages/turbo-codemod/src/commands/migrate/utils.ts new file mode 100644 index 0000000..512d78b --- /dev/null +++ b/packages/turbo-codemod/src/commands/migrate/utils.ts @@ -0,0 +1,16 @@ +import { execSync, ExecSyncOptions } from "child_process"; + +function exec( + command: string, + opts: ExecSyncOptions, + fallback?: string +): string | undefined { + try { + const rawResult = execSync(command, opts); + return rawResult.toString("utf8").trim(); + } catch (err) { + return fallback || undefined; + } +} + +export { exec }; diff --git a/packages/turbo-codemod/src/commands/transform/index.ts b/packages/turbo-codemod/src/commands/transform/index.ts new file mode 100644 index 0000000..e3b86aa --- /dev/null +++ b/packages/turbo-codemod/src/commands/transform/index.ts @@ -0,0 +1,101 @@ +import chalk from "chalk"; +import inquirer from "inquirer"; + +import loadTransformers from "../../utils/loadTransformers"; +import checkGitStatus from "../../utils/checkGitStatus"; +import directoryInfo from "../../utils/directoryInfo"; +import type { + TransformCommandOptions, + TransformCommandArgument, +} from "./types"; +import { Runner } from "../../runner"; + +export default async function transform( + transform: TransformCommandArgument, + directory: TransformCommandArgument, + options: TransformCommandOptions +) { + const transforms = loadTransformers(); + if (options.list) { + console.log( + transforms + .map((transform) => `- ${chalk.cyan(transform.value)}`) + .join("\n") + ); + return process.exit(0); + } + + // check git status + if (!options.dry) { + checkGitStatus({ directory, force: options.force }); + } + + const answers = await inquirer.prompt<{ + directoryInput?: string; + transformerInput?: string; + }>([ + { + type: "input", + name: "directoryInput", + message: "Where is the root of the repo where the transform should run?", + when: !directory, + default: ".", + validate: (directory: string) => { + const { exists, absolute } = directoryInfo({ directory }); + if (exists) { + return true; + } else { + return `Directory ${chalk.dim(`(${absolute})`)} does not exist`; + } + }, + filter: (directory: string) => directory.trim(), + }, + { + type: "list", + name: "transformerInput", + message: "Which transform would you like to apply?", + when: !transform, + pageSize: transforms.length, + choices: transforms, + }, + ]); + + const { + directoryInput: selectedDirectory = directory as string, + transformerInput: selectedTransformer = transform as string, + } = answers; + const { exists, absolute: root } = directoryInfo({ + directory: selectedDirectory, + }); + if (!exists) { + console.error(`Directory ${chalk.dim(`(${root})`)} does not exist`); + return process.exit(1); + } + + const transformKeys = transforms.map((transform) => transform.value); + const transformData = transforms.find( + (transform) => transform.value === selectedTransformer + ); + + // validate transforms + if (!transformData) { + console.error( + `Invalid transform choice ${chalk.dim(`(${transform})`)}, pick one of:` + ); + console.error(transformKeys.map((key) => `- ${key}`).join("\n")); + return process.exit(1); + } + + // run the transform + const result = transformData.transformer({ + root, + options, + }); + + if (result.fatalError) { + // Runner already logs this, so we can just exit + return process.exit(1); + } + + Runner.logResults(result); +} diff --git a/packages/turbo-codemod/src/commands/transform/types.ts b/packages/turbo-codemod/src/commands/transform/types.ts new file mode 100644 index 0000000..9ac2db0 --- /dev/null +++ b/packages/turbo-codemod/src/commands/transform/types.ts @@ -0,0 +1,7 @@ +import { TransformerOptions } from "../../types"; + +export type TransformCommandArgument = "string" | undefined; + +export interface TransformCommandOptions extends TransformerOptions { + list: boolean; +} diff --git a/packages/turbo-codemod/src/runner/FileTransform.ts b/packages/turbo-codemod/src/runner/FileTransform.ts new file mode 100644 index 0000000..3b23f73 --- /dev/null +++ b/packages/turbo-codemod/src/runner/FileTransform.ts @@ -0,0 +1,94 @@ +import chalk from "chalk"; +import { diffLines, Change, diffJson } from "diff"; +import fs from "fs-extra"; +import os from "os"; +import path from "path"; + +import type { FileTransformArgs, LogFileArgs } from "./types"; + +export default class FileTransform { + filePath: string; + rootPath: string; + before: string | object; + after?: string | object; + error?: Error; + changes: Array = []; + + constructor(args: FileTransformArgs) { + this.filePath = args.filePath; + this.rootPath = args.rootPath; + this.after = args.after; + this.error = args.error; + + // load original file for comparison + if (args.before === undefined) { + try { + if (path.extname(args.filePath) === ".json") { + this.before = fs.readJsonSync(args.filePath); + } else { + this.before = fs.readFileSync(args.filePath); + } + } catch (err) { + this.before = ""; + } + } else if (args.before === null) { + this.before = ""; + } else { + this.before = args.before; + } + + // determine diff + if (args.after) { + if (typeof this.before === "object" || typeof args.after === "object") { + this.changes = diffJson(this.before, args.after); + } else { + this.changes = diffLines(this.before, args.after); + } + } else { + this.changes = []; + } + } + + fileName(): string { + return path.relative(this.rootPath, this.filePath); + } + + write(): void { + if (this.after) { + if (typeof this.after === "object") { + fs.writeJsonSync(this.filePath, this.after, { spaces: 2 }); + } else { + fs.writeFileSync(this.filePath, this.after); + } + } + } + + additions(): number { + return this.changes.filter((c) => c.added).length; + } + + deletions(): number { + return this.changes.filter((c) => c.removed).length; + } + + hasChanges(): boolean { + return this.additions() > 0 || this.deletions() > 0; + } + + log(args: LogFileArgs): void { + if (args.diff) { + this.changes.forEach((part) => { + if (part.added) { + process.stdout.write(chalk.green(part.value)); + } else if (part.removed) { + process.stdout.write(chalk.red(part.value)); + } else { + process.stdout.write(chalk.dim(part.value)); + } + }); + console.log(os.EOL); + } else { + console.log(this.after); + } + } +} diff --git a/packages/turbo-codemod/src/runner/Runner.ts b/packages/turbo-codemod/src/runner/Runner.ts new file mode 100644 index 0000000..8f8803d --- /dev/null +++ b/packages/turbo-codemod/src/runner/Runner.ts @@ -0,0 +1,132 @@ +import chalk from "chalk"; + +import FileTransform from "./FileTransform"; +import Logger from "../utils/logger"; +import type { UtilityArgs } from "../types"; +import type { + FileResult, + ModifyFileArgs, + AbortTransformArgs, + TransformerResults, +} from "./types"; + +class Runner { + transform: string; + rootPath: string; + dry: boolean; + print: boolean; + modifications: Record = {}; + logger: Logger; + + constructor(options: UtilityArgs) { + this.transform = options.transformer; + this.rootPath = options.rootPath; + this.dry = options.dry; + this.print = options.print; + this.logger = new Logger(options); + } + + abortTransform(args: AbortTransformArgs): TransformerResults { + this.logger.error(args.reason); + return { + fatalError: new Error(args.reason), + changes: args.changes || {}, + }; + } + + // add a file to be transformed + modifyFile(args: ModifyFileArgs): void { + this.modifications[args.filePath] = new FileTransform({ + rootPath: this.rootPath, + ...args, + }); + } + + // execute all transforms and track results for reporting + finish(): TransformerResults { + const results: TransformerResults = { changes: {} }; + // perform all actions and track results + Object.keys(this.modifications).forEach((filePath) => { + const mod = this.modifications[filePath]; + const result: FileResult = { + action: "unchanged", + additions: mod.additions(), + deletions: mod.deletions(), + }; + + if (mod.hasChanges()) { + if (this.dry) { + result.action = "skipped"; + this.logger.skipped(chalk.dim(mod.fileName())); + } else { + try { + mod.write(); + result.action = "modified"; + this.logger.modified(chalk.bold(mod.fileName())); + } catch (err) { + let message = "Unknown error"; + if (err instanceof Error) { + message = err.message; + } + result.error = new Error(message); + result.action = "error"; + this.logger.error(mod.fileName(), message); + } + } + + if (this.print) { + mod.log({ diff: true }); + } + } else { + this.logger.unchanged(chalk.dim(mod.fileName())); + } + + results.changes[mod.fileName()] = result; + }); + + const encounteredError = Object.keys(results.changes).some((fileName) => { + return results.changes[fileName].action === "error"; + }); + + if (encounteredError) { + return this.abortTransform({ + reason: "Encountered an error while transforming files", + changes: results.changes, + }); + } + + return results; + } + + static logResults(results: TransformerResults): void { + const changedFiles = Object.keys(results.changes); + console.log(); + if (changedFiles.length > 0) { + console.log(chalk.bold(`Results:`)); + const table: Record< + string, + { + action: FileResult["action"]; + additions: FileResult["additions"]; + deletions: FileResult["deletions"]; + error?: string; + } + > = {}; + + changedFiles.forEach((fileName) => { + const fileChanges = results.changes[fileName]; + table[fileName] = { + action: fileChanges.action, + additions: fileChanges.additions, + deletions: fileChanges.deletions, + error: fileChanges.error?.message || "None", + }; + }); + + console.table(table); + console.log(); + } + } +} + +export default Runner; diff --git a/packages/turbo-codemod/src/runner/index.ts b/packages/turbo-codemod/src/runner/index.ts new file mode 100644 index 0000000..2aa323d --- /dev/null +++ b/packages/turbo-codemod/src/runner/index.ts @@ -0,0 +1,3 @@ +export { default as Runner } from "./Runner"; + +export type { TransformerResults, FileDiffer, FileWriter } from "./types"; diff --git a/packages/turbo-codemod/src/runner/types.ts b/packages/turbo-codemod/src/runner/types.ts new file mode 100644 index 0000000..e7c37d4 --- /dev/null +++ b/packages/turbo-codemod/src/runner/types.ts @@ -0,0 +1,40 @@ +import { Change } from "diff"; + +export interface FileResult { + action: "skipped" | "modified" | "unchanged" | "error"; + error?: Error; + additions: number; + deletions: number; +} + +export interface FileTransformArgs extends ModifyFileArgs { + rootPath: string; +} + +export interface ModifyFileArgs { + filePath: string; + before?: string | object; + after?: string | object; + error?: Error; +} + +export interface AbortTransformArgs { + reason: string; + changes?: Record; +} + +export interface LogFileArgs { + diff?: boolean; +} + +export type FileWriter = (filePath: string, contents: string | object) => void; + +export type FileDiffer = ( + before: string | object, + after: string | object +) => Array; + +export interface TransformerResults { + fatalError?: Error; + changes: Record; +} diff --git a/packages/turbo-codemod/src/transforms/README.md b/packages/turbo-codemod/src/transforms/README.md new file mode 100644 index 0000000..8e4430f --- /dev/null +++ b/packages/turbo-codemod/src/transforms/README.md @@ -0,0 +1,36 @@ +# `@turbo/codemod` Transformers + +## Adding new transformers + +Add new transformers using the [plopjs](https://github.com/plopjs/plop) template by running: + +```bash +pnpm add-transformer +``` + +New Transformers will be automatically surfaced to the `transform` CLI command and used by the `migrate` CLI command when appropriate. + +## How it works + +Transformers are loaded automatically from the `src/transforms/` directory via the [`loadTransforms`](../utils/loadTransformers.ts) function. + +All new transformers must contain a default export that matches the [`Transformer`](../types.ts) type: + +```ts +export type Transformer = { + name: string; + value: string; + introducedIn: string; + transformer: (args: TransformerArgs) => TransformerResults; +}; +``` + +## Writing a Transform + +Transforms are ran using the [TransformRunner](../runner/Runner.ts). This class is designed to make writing transforms as simple as possible by abstracting away all of the boilerplate that determines what should be logged, saved, or output as a result. + +To use the TransformRunner: + +1. Transform each file in memory (do not write it back to disk `TransformRunner` takes care of this depending on the options passed in by the user), and pass to `TransformRunner.modifyFile` method. +2. If the transform encounters an unrecoverable error, pass it to the `TransformRunner.abortTransform` method. +3. When all files have been modified and passed to `TransformRunner.modifyFile`, call `TransformRunner.finish` method to write the files to disk (when not running in `dry` mode) and log the results. diff --git a/packages/turbo-codemod/src/transforms/add-package-manager.ts b/packages/turbo-codemod/src/transforms/add-package-manager.ts new file mode 100644 index 0000000..bd6581f --- /dev/null +++ b/packages/turbo-codemod/src/transforms/add-package-manager.ts @@ -0,0 +1,75 @@ +import path from "path"; +import fs from "fs-extra"; + +import getPackageManager from "../utils/getPackageManager"; +import getPackageManagerVersion from "../utils/getPackageManagerVersion"; +import getTransformerHelpers from "../utils/getTransformerHelpers"; +import { TransformerResults } from "../runner"; +import type { TransformerArgs } from "../types"; + +// transformer details +const TRANSFORMER = "add-package-manager"; +const DESCRIPTION = "Set the `packageManager` key in root `package.json` file"; +const INTRODUCED_IN = "1.1.0"; + +export function transformer({ + root, + options, +}: TransformerArgs): TransformerResults { + const { log, runner } = getTransformerHelpers({ + transformer: TRANSFORMER, + rootPath: root, + options, + }); + + log.info(`Set "packageManager" key in root "package.json" file...`); + const packageManager = getPackageManager({ directory: root }); + if (!packageManager) { + return runner.abortTransform({ + reason: `Unable to determine package manager for ${root}`, + }); + } + + // handle workspaces... + let version = null; + try { + version = getPackageManagerVersion(packageManager, root); + } catch (err) { + return runner.abortTransform({ + reason: `Unable to determine package manager version for ${root}`, + }); + } + const pkgManagerString = `${packageManager}@${version}`; + const rootPackageJsonPath = path.join(root, "package.json"); + const rootPackageJson = fs.readJsonSync(rootPackageJsonPath); + const allWorkspaces = [ + { + name: "package.json", + path: root, + packageJson: { + ...rootPackageJson, + packageJsonPath: rootPackageJsonPath, + }, + }, + ]; + + for (const workspace of allWorkspaces) { + const { packageJsonPath, ...pkgJson } = workspace.packageJson; + const newJson = { ...pkgJson, packageManager: pkgManagerString }; + runner.modifyFile({ + filePath: packageJsonPath, + after: newJson, + }); + } + + return runner.finish(); +} + +const transformerMeta = { + name: `${TRANSFORMER}: ${DESCRIPTION}`, + value: TRANSFORMER, + introducedIn: INTRODUCED_IN, + transformer, +}; + +export default transformerMeta; diff --git a/packages/turbo-codemod/src/transforms/create-turbo-config.ts b/packages/turbo-codemod/src/transforms/create-turbo-config.ts new file mode 100644 index 0000000..0e8549a --- /dev/null +++ b/packages/turbo-codemod/src/transforms/create-turbo-config.ts @@ -0,0 +1,70 @@ +import fs from "fs-extra"; +import path from "path"; + +import { TransformerResults } from "../runner"; +import getTransformerHelpers from "../utils/getTransformerHelpers"; +import type { TransformerArgs } from "../types"; + +// transformer details +const TRANSFORMER = "create-turbo-config"; +const DESCRIPTION = + 'Create the `turbo.json` file from an existing "turbo" key in `package.json`'; +const INTRODUCED_IN = "1.1.0"; + +export function transformer({ + root, + options, +}: TransformerArgs): TransformerResults { + const { log, runner } = getTransformerHelpers({ + transformer: TRANSFORMER, + rootPath: root, + options, + }); + + log.info(`Migrating "package.json" "turbo" key to "turbo.json" file...`); + const turboConfigPath = path.join(root, "turbo.json"); + const rootPackageJsonPath = path.join(root, "package.json"); + if (!fs.existsSync(rootPackageJsonPath)) { + return runner.abortTransform({ + reason: `No package.json found at ${root}. Is the path correct?`, + }); + } + + // read files + const rootPackageJson = fs.readJsonSync(rootPackageJsonPath); + let rootTurboJson = null; + try { + rootTurboJson = fs.readJSONSync(turboConfigPath); + } catch (err) { + rootTurboJson = null; + } + + // modify files + let transformedPackageJson = rootPackageJson; + let transformedTurboConfig = rootTurboJson; + if (!rootTurboJson && rootPackageJson["turbo"]) { + const { turbo: turboConfig, ...remainingPkgJson } = rootPackageJson; + transformedTurboConfig = turboConfig; + transformedPackageJson = remainingPkgJson; + } + + runner.modifyFile({ + filePath: turboConfigPath, + after: transformedTurboConfig, + }); + runner.modifyFile({ + filePath: rootPackageJsonPath, + after: transformedPackageJson, + }); + + return runner.finish(); +} + +const transformerMeta = { + name: `${TRANSFORMER}: ${DESCRIPTION}`, + value: TRANSFORMER, + introducedIn: INTRODUCED_IN, + transformer, +}; + +export default transformerMeta; diff --git a/packages/turbo-codemod/src/transforms/migrate-env-var-dependencies.ts b/packages/turbo-codemod/src/transforms/migrate-env-var-dependencies.ts new file mode 100644 index 0000000..ef3a34c --- /dev/null +++ b/packages/turbo-codemod/src/transforms/migrate-env-var-dependencies.ts @@ -0,0 +1,181 @@ +import fs from "fs-extra"; +import path from "path"; +import { getTurboConfigs } from "@turbo/utils"; +import type { Schema, Pipeline } from "@turbo/types"; + +import getTransformerHelpers from "../utils/getTransformerHelpers"; +import { TransformerResults } from "../runner"; +import type { TransformerArgs } from "../types"; + +// transformer details +const TRANSFORMER = "migrate-env-var-dependencies"; +const DESCRIPTION = + 'Migrate environment variable dependencies from "dependsOn" to "env" in `turbo.json`'; +const INTRODUCED_IN = "1.5.0"; + +export function hasLegacyEnvVarDependencies(config: Schema) { + const dependsOn = [ + "extends" in config ? [] : config.globalDependencies, + Object.values(config.pipeline).flatMap( + (pipeline) => pipeline.dependsOn ?? [] + ), + ].flat(); + const envVars = dependsOn.filter((dep) => dep?.startsWith("$")); + return { hasKeys: !!envVars.length, envVars }; +} + +export function migrateDependencies({ + env, + deps, +}: { + env?: string[]; + deps?: string[]; +}) { + const envDeps: Set = new Set(env); + const otherDeps: string[] = []; + deps?.forEach((dep) => { + if (dep.startsWith("$")) { + envDeps.add(dep.slice(1)); + } else { + otherDeps.push(dep); + } + }); + if (envDeps.size) { + return { + deps: otherDeps, + env: Array.from(envDeps), + }; + } else { + return { env, deps }; + } +} + +export function migratePipeline(pipeline: Pipeline) { + const { deps: dependsOn, env } = migrateDependencies({ + env: pipeline.env, + deps: pipeline.dependsOn, + }); + const migratedPipeline = { ...pipeline }; + if (dependsOn) { + migratedPipeline.dependsOn = dependsOn; + } else { + delete migratedPipeline.dependsOn; + } + if (env && env.length) { + migratedPipeline.env = env; + } else { + delete migratedPipeline.env; + } + + return migratedPipeline; +} + +export function migrateGlobal(config: Schema) { + if ("extends" in config) { + return config; + } + + const { deps: globalDependencies, env } = migrateDependencies({ + env: config.globalEnv, + deps: config.globalDependencies, + }); + const migratedConfig = { ...config }; + if (globalDependencies && globalDependencies.length) { + migratedConfig.globalDependencies = globalDependencies; + } else { + delete migratedConfig.globalDependencies; + } + if (env && env.length) { + migratedConfig.globalEnv = env; + } else { + delete migratedConfig.globalEnv; + } + return migratedConfig; +} + +export function migrateConfig(config: Schema) { + let migratedConfig = migrateGlobal(config); + Object.keys(config.pipeline).forEach((pipelineKey) => { + config.pipeline; + if (migratedConfig.pipeline && config.pipeline[pipelineKey]) { + const pipeline = migratedConfig.pipeline[pipelineKey]; + migratedConfig.pipeline[pipelineKey] = { + ...pipeline, + ...migratePipeline(pipeline), + }; + } + }); + return migratedConfig; +} + +export function transformer({ + root, + options, +}: TransformerArgs): TransformerResults { + const { log, runner } = getTransformerHelpers({ + transformer: TRANSFORMER, + rootPath: root, + options, + }); + + log.info( + `Migrating environment variable dependencies from "globalDependencies" and "dependsOn" to "env" in "turbo.json"...` + ); + + // validate we don't have a package.json config + const packageJsonPath = path.join(root, "package.json"); + let packageJSON = {}; + try { + packageJSON = fs.readJSONSync(packageJsonPath); + } catch (e) { + // readJSONSync probably failed because the file doesn't exist + } + + if ("turbo" in packageJSON) { + return runner.abortTransform({ + reason: + '"turbo" key detected in package.json. Run `npx @turbo/codemod transform create-turbo-config` first', + }); + } + + // validate we have a root config + const turboConfigPath = path.join(root, "turbo.json"); + if (!fs.existsSync(turboConfigPath)) { + return runner.abortTransform({ + reason: `No turbo.json found at ${root}. Is the path correct?`, + }); + } + + let turboJson: Schema = fs.readJsonSync(turboConfigPath); + if (hasLegacyEnvVarDependencies(turboJson).hasKeys) { + turboJson = migrateConfig(turboJson); + } + + runner.modifyFile({ + filePath: turboConfigPath, + after: turboJson, + }); + + // find and migrate any workspace configs + const workspaceConfigs = getTurboConfigs(root); + workspaceConfigs.forEach((workspaceConfig) => { + const { config, turboConfigPath } = workspaceConfig; + if (hasLegacyEnvVarDependencies(config).hasKeys) { + runner.modifyFile({ + filePath: turboConfigPath, + after: migrateConfig(config), + }); + } + }); + + return runner.finish(); +} + +const transformerMeta = { + name: `${TRANSFORMER}: ${DESCRIPTION}`, + value: TRANSFORMER, + introducedIn: INTRODUCED_IN, + transformer, +}; + +export default transformerMeta; diff --git a/packages/turbo-codemod/src/transforms/set-default-outputs.ts b/packages/turbo-codemod/src/transforms/set-default-outputs.ts new file mode 100644 index 0000000..44f7fd1 --- /dev/null +++ b/packages/turbo-codemod/src/transforms/set-default-outputs.ts @@ -0,0 +1,97 @@ +import path from "path"; +import fs from "fs-extra"; +import { getTurboConfigs } from "@turbo/utils"; +import type { Schema as TurboJsonSchema } from "@turbo/types"; + +import type { TransformerArgs } from "../types"; +import getTransformerHelpers from "../utils/getTransformerHelpers"; +import { TransformerResults } from "../runner"; + +const DEFAULT_OUTPUTS = ["dist/**", "build/**"]; + +// transformer details +const TRANSFORMER = "set-default-outputs"; +const DESCRIPTION = + 'Add the "outputs" key with defaults where it is missing in `turbo.json`'; +const INTRODUCED_IN = "1.7.0"; + +function migrateConfig(config: TurboJsonSchema) { + for (const [_, taskDef] of Object.entries(config.pipeline)) { + if (taskDef.cache !== false) { + if (!taskDef.outputs) { + taskDef.outputs = DEFAULT_OUTPUTS; + } else if ( + Array.isArray(taskDef.outputs) && + taskDef.outputs.length === 0 + ) { + delete taskDef.outputs; + } + } + } + + return config; +} + +export function transformer({ + root, + options, +}: TransformerArgs): TransformerResults { + const { log, runner } = getTransformerHelpers({ + transformer: TRANSFORMER, + rootPath: root, + options, + }); + + // If `turbo` key is detected in package.json, require user to run the other codemod first. + const packageJsonPath = path.join(root, "package.json"); + // package.json should always exist, but if it doesn't, it would be a silly place to blow up this codemod + let packageJSON = {}; + + try { + packageJSON = fs.readJSONSync(packageJsonPath); + } catch (e) { + // readJSONSync probably failed because the file doesn't exist + } + + if ("turbo" in packageJSON) { + return runner.abortTransform({ + reason: + '"turbo" key detected in package.json. Run `npx @turbo/codemod transform create-turbo-config` first', + }); + } + + log.info(`Adding default \`outputs\` key into tasks if it doesn't exist`); + const turboConfigPath = path.join(root, "turbo.json"); + if (!fs.existsSync(turboConfigPath)) { + return runner.abortTransform({ + reason: `No turbo.json found at ${root}. Is the path correct?`, + }); + } + + const turboJson: TurboJsonSchema = fs.readJsonSync(turboConfigPath); + runner.modifyFile({ + filePath: turboConfigPath, + after: migrateConfig(turboJson), + }); + + // find and migrate any workspace configs + const workspaceConfigs = getTurboConfigs(root); + workspaceConfigs.forEach((workspaceConfig) => { + const { config, turboConfigPath } = workspaceConfig; + runner.modifyFile({ + filePath: turboConfigPath, + after: migrateConfig(config), + }); + }); + + return runner.finish(); +} + +const transformerMeta = { + name: `${TRANSFORMER}: ${DESCRIPTION}`, + value: TRANSFORMER, + introducedIn: INTRODUCED_IN, + transformer, +}; + +export default transformerMeta; diff --git a/packages/turbo-codemod/src/types.ts b/packages/turbo-codemod/src/types.ts new file mode 100644 index 0000000..d5c13c3 --- /dev/null +++ b/packages/turbo-codemod/src/types.ts @@ -0,0 +1,24 @@ +import { TransformerResults } from "./runner"; + +export type Transformer = { + name: string; + value: string; + introducedIn: string; + transformer: (args: TransformerArgs) => TransformerResults; +}; + +export type TransformerOptions = { + force: boolean; + dry: boolean; + print: boolean; +}; + +export type TransformerArgs = { + root: string; + options: TransformerOptions; +}; + +export interface UtilityArgs extends TransformerOptions { + transformer: string; + rootPath: string; +} diff --git a/packages/turbo-codemod/src/utils/checkGitStatus.ts b/packages/turbo-codemod/src/utils/checkGitStatus.ts new file mode 100644 index 0000000..68d39ae --- /dev/null +++ b/packages/turbo-codemod/src/utils/checkGitStatus.ts @@ -0,0 +1,40 @@ +import chalk from "chalk"; +import isGitClean from "is-git-clean"; + +export default function checkGitStatus({ + directory, + force, +}: { + directory?: string; + force: boolean; +}) { + let clean = false; + let errorMessage = "Unable to determine if git directory is clean"; + try { + clean = isGitClean.sync(directory || process.cwd()); + errorMessage = "Git directory is not clean"; + } catch (err: any) { + if (err && err.stderr && err.stderr.indexOf("not a git repository") >= 0) { + clean = true; + } + } + + if (!clean) { + if (force) { + console.log( + `${chalk.yellow("WARNING")}: ${errorMessage}. Forcibly continuing...` + ); + } else { + console.log("Thank you for using @turbo/codemod!"); + console.log( + chalk.yellow( + "\nBut before we continue, please stash or commit your git changes." + ) + ); + console.log( + "\nYou may use the --force flag to override this safety check." + ); + process.exit(1); + } + } +} diff --git a/packages/turbo-codemod/src/utils/directoryInfo.ts b/packages/turbo-codemod/src/utils/directoryInfo.ts new file mode 100644 index 0000000..7cb3594 --- /dev/null +++ b/packages/turbo-codemod/src/utils/directoryInfo.ts @@ -0,0 +1,10 @@ +import path from "path"; +import fs from "fs"; + +export default function directoryInfo({ directory }: { directory: string }) { + const dir = path.isAbsolute(directory) + ? directory + : path.join(process.cwd(), directory); + + return { exists: fs.existsSync(dir), absolute: dir }; +} diff --git a/packages/turbo-codemod/src/utils/getPackageManager.ts b/packages/turbo-codemod/src/utils/getPackageManager.ts new file mode 100644 index 0000000..1df0acc --- /dev/null +++ b/packages/turbo-codemod/src/utils/getPackageManager.ts @@ -0,0 +1,42 @@ +import findUp from "find-up"; +import path from "path"; + +export type PackageManager = "yarn" | "pnpm" | "npm"; + +const cache: { [cwd: string]: PackageManager } = {}; + +export default function getPackageManager({ + directory, +}: { directory?: string } = {}): PackageManager | undefined { + const cwd = directory || process.cwd(); + if (cache[cwd]) { + return cache[cwd]; + } + + const lockFile = findUp.sync( + ["yarn.lock", "pnpm-lock.yaml", "package-lock.json"], + { + cwd, + } + ); + + if (!lockFile) { + return; + } + + switch (path.basename(lockFile)) { + case "yarn.lock": + cache[cwd] = "yarn"; + break; + + case "pnpm-lock.yaml": + cache[cwd] = "pnpm"; + break; + + case "package-lock.json": + cache[cwd] = "npm"; + break; + } + + return cache[cwd]; +} diff --git a/packages/turbo-codemod/src/utils/getPackageManagerVersion.ts b/packages/turbo-codemod/src/utils/getPackageManagerVersion.ts new file mode 100644 index 0000000..54a572a --- /dev/null +++ b/packages/turbo-codemod/src/utils/getPackageManagerVersion.ts @@ -0,0 +1,16 @@ +import { execSync } from "child_process"; +import type { PackageManager } from "./getPackageManager"; + +export default function getPackageManagerVersion( + packageManager: PackageManager, + root: string +): string { + switch (packageManager) { + case "yarn": + return execSync("yarn --version", { cwd: root }).toString().trim(); + case "pnpm": + return execSync("pnpm --version", { cwd: root }).toString().trim(); + case "npm": + return execSync("npm --version", { cwd: root }).toString().trim(); + } +} diff --git a/packages/turbo-codemod/src/utils/getTransformerHelpers.ts b/packages/turbo-codemod/src/utils/getTransformerHelpers.ts new file mode 100644 index 0000000..e37da6e --- /dev/null +++ b/packages/turbo-codemod/src/utils/getTransformerHelpers.ts @@ -0,0 +1,23 @@ +import { TransformerOptions } from "../types"; +import { Runner } from "../runner"; +import Logger from "./logger"; + +export default function getTransformerHelpers({ + transformer, + rootPath, + options, +}: { + transformer: string; + rootPath: string; + options: TransformerOptions; +}) { + const utilArgs = { + transformer, + rootPath, + ...options, + }; + const log = new Logger(utilArgs); + const runner = new Runner(utilArgs); + + return { log, runner }; +} diff --git a/packages/turbo-codemod/src/utils/loadTransformers.ts b/packages/turbo-codemod/src/utils/loadTransformers.ts new file mode 100644 index 0000000..9ba5ca1 --- /dev/null +++ b/packages/turbo-codemod/src/utils/loadTransformers.ts @@ -0,0 +1,27 @@ +import path from "path"; +import fs from "fs-extra"; +import type { Transformer } from "../types"; + +// transforms/ is a sibling when built in in dist/ +export const transformerDirectory = + process.env.NODE_ENV === "test" + ? path.join(__dirname, "../transforms") + : path.join(__dirname, "./transforms"); + +export default function loadTransformers(): Array { + const transformerFiles = fs.readdirSync(transformerDirectory); + return transformerFiles + .map((transformerFilename) => { + const transformerPath = path.join( + transformerDirectory, + transformerFilename + ); + try { + return require(transformerPath).default; + } catch (e) { + // we ignore this error because it's likely that the file is not a transformer (README, etc) + return undefined; + } + }) + .filter(Boolean); +} diff --git a/packages/turbo-codemod/src/utils/logger.ts b/packages/turbo-codemod/src/utils/logger.ts new file mode 100644 index 0000000..123a836 --- /dev/null +++ b/packages/turbo-codemod/src/utils/logger.ts @@ -0,0 +1,47 @@ +import chalk from "chalk"; +import { UtilityArgs } from "../types"; + +export default class Logger { + transform: string; + dry: boolean; + + constructor(args: UtilityArgs) { + this.transform = args.transformer; + this.dry = args.dry; + } + modified(...args: any[]) { + console.log( + chalk.green(` MODIFIED `), + ...args, + this.dry ? chalk.dim(`(dry run)`) : "" + ); + } + unchanged(...args: any[]) { + console.log( + chalk.gray(` UNCHANGED `), + ...args, + this.dry ? chalk.dim(`(dry run)`) : "" + ); + } + skipped(...args: any[]) { + console.log( + chalk.yellow(` SKIPPED `), + ...args, + this.dry ? chalk.dim(`(dry run)`) : "" + ); + } + error(...args: any[]) { + console.log( + chalk.red(` ERROR `), + ...args, + this.dry ? chalk.dim(`(dry run)`) : "" + ); + } + info(...args: any[]) { + console.log( + chalk.bold(` INFO `), + ...args, + this.dry ? chalk.dim(`(dry run)`) : "" + ); + } +} diff --git a/packages/turbo-codemod/src/utils/looksLikeRepo.ts b/packages/turbo-codemod/src/utils/looksLikeRepo.ts new file mode 100644 index 0000000..77f0e5c --- /dev/null +++ b/packages/turbo-codemod/src/utils/looksLikeRepo.ts @@ -0,0 +1,12 @@ +import path from "path"; +import { existsSync } from "fs-extra"; + +const HINTS = ["package.json", "turbo.json", ".git"]; + +export default function looksLikeRepo({ + directory, +}: { + directory: string; +}): boolean { + return HINTS.some((hint) => existsSync(path.join(directory, hint))); +} diff --git a/packages/turbo-codemod/src/utils/notifyUpdate.ts b/packages/turbo-codemod/src/utils/notifyUpdate.ts new file mode 100644 index 0000000..634ffd8 --- /dev/null +++ b/packages/turbo-codemod/src/utils/notifyUpdate.ts @@ -0,0 +1,35 @@ +import chalk from "chalk"; +import checkForUpdate from "update-check"; + +import cliPkgJson from "../../package.json"; +import getWorkspaceImplementation from "./getPackageManager"; + +const update = checkForUpdate(cliPkgJson).catch(() => null); + +export default async function notifyUpdate(): Promise { + try { + const res = await update; + if (res?.latest) { + const ws = getWorkspaceImplementation(); + + console.log(); + console.log( + chalk.yellow.bold("A new version of `@turbo/codemod` is available!") + ); + console.log( + "You can update by running: " + + chalk.cyan( + ws === "yarn" + ? "yarn global add @turbo/codemod" + : ws === "pnpm" + ? "pnpm i -g @turbo/codemod" + : "npm i -g @turbo/codemod" + ) + ); + console.log(); + } + process.exit(); + } catch (_e: any) { + // ignore error + } +} -- cgit v1.2.3-70-g09d2