diff options
| author | 2023-04-28 01:36:44 +0800 | |
|---|---|---|
| committer | 2023-04-28 01:36:44 +0800 | |
| commit | dd84b9d64fb98746a230cd24233ff50a562c39c9 (patch) | |
| tree | b583261ef00b3afe72ec4d6dacb31e57779a6faf /packages/turbo-workspaces/src | |
| parent | 0b46fcd72ac34382387b2bcf9095233efbcc52f4 (diff) | |
| download | HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.tar.gz HydroRoll-dd84b9d64fb98746a230cd24233ff50a562c39c9.zip | |
Diffstat (limited to 'packages/turbo-workspaces/src')
19 files changed, 1847 insertions, 0 deletions
diff --git a/packages/turbo-workspaces/src/cli.ts b/packages/turbo-workspaces/src/cli.ts new file mode 100644 index 0000000..3ae7a45 --- /dev/null +++ b/packages/turbo-workspaces/src/cli.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import chalk from "chalk"; +import { Command } from "commander"; + +import { summary, convert } from "./commands"; +import cliPkg from "../package.json"; +import { ConvertError } from "./errors"; + +const workspacesCli = new Command(); + +workspacesCli + .name("@turbo/workspaces") + .description("Tools for working with package manager workspaces") + .version(cliPkg.version, "-v, --version", "output the current version"); + +// convert +workspacesCli + .command("convert") + .description("Convert project between workspace managers") + .argument("[path]", "Project root") + .argument("[package-manager]", "Package manager to convert to") + .option( + "--skip-install", + "Do not run a package manager install after conversion", + false + ) + .option("--dry", "Dry run (no changes are made to files)", false) + .option( + "--force", + "Bypass Git safety checks and forcibly run conversion", + false + ) + .action(convert); + +// summary +workspacesCli + .command("summary") + .description("Display a summary of the specified project") + .argument("[path]", "Project root") + .action(summary); + +workspacesCli.parseAsync().catch((error) => { + console.log(); + if (error instanceof ConvertError) { + console.log(chalk.red(error.message)); + } else { + console.log(chalk.red("Unexpected error. Please report it as a bug:")); + console.log(error.message); + } + console.log(); + process.exit(1); +}); diff --git a/packages/turbo-workspaces/src/commands/convert/index.ts b/packages/turbo-workspaces/src/commands/convert/index.ts new file mode 100644 index 0000000..6cc82fc --- /dev/null +++ b/packages/turbo-workspaces/src/commands/convert/index.ts @@ -0,0 +1,109 @@ +import { ConvertCommandArgument, ConvertCommandOptions } from "./types"; +import inquirer from "inquirer"; +import { Logger } from "../../logger"; +import chalk from "chalk"; +import { getAvailablePackageManagers } from "@turbo/utils"; +import { directoryInfo } from "../../utils"; +import getWorkspaceDetails from "../../getWorkspaceDetails"; +import { PackageManager } from "../../types"; +import { convertProject } from "../../convert"; + +function isPackageManagerDisabled({ + packageManager, + currentWorkspaceManger, + availablePackageManagers, +}: { + packageManager: PackageManager; + currentWorkspaceManger: PackageManager; + availablePackageManagers: Record<PackageManager, { available: boolean }>; +}) { + if (currentWorkspaceManger === packageManager) { + return "already in use"; + } + + if (!availablePackageManagers[packageManager].available) { + return "not installed"; + } + + return false; +} + +export default async function convertCommand( + directory: ConvertCommandArgument, + packageManager: ConvertCommandArgument, + options: ConvertCommandOptions +) { + const logger = new Logger(options); + + logger.hero(); + logger.header("Welcome, let's convert your project."); + logger.blankLine(); + + const directoryAnswer = await inquirer.prompt<{ + directoryInput: string; + }>({ + type: "input", + name: "directoryInput", + message: "Where is the root of your repo?", + 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 } = directoryAnswer; + const { exists, absolute: root } = directoryInfo({ + directory: selectedDirectory, + }); + if (!exists) { + console.error(`Directory ${chalk.dim(`(${root})`)} does not exist`); + return process.exit(1); + } + + const [project, availablePackageManagers] = await Promise.all([ + getWorkspaceDetails({ root }), + getAvailablePackageManagers(), + ]); + + const packageManagerAnswer = await inquirer.prompt<{ + packageManagerInput?: PackageManager; + }>({ + name: "packageManagerInput", + type: "list", + message: `Convert from ${project.packageManager} workspaces to:`, + when: + !packageManager || + !Object.keys(availablePackageManagers).includes(packageManager), + choices: ["npm", "pnpm", "yarn"].map((p) => ({ + name: `${p} workspaces`, + value: p, + disabled: isPackageManagerDisabled({ + packageManager: p as PackageManager, + currentWorkspaceManger: project.packageManager, + availablePackageManagers, + }), + })), + }); + const { + packageManagerInput: + selectedPackageManager = packageManager as PackageManager, + } = packageManagerAnswer; + + await convertProject({ + project, + to: { + name: selectedPackageManager, + version: availablePackageManagers[selectedPackageManager] + .version as string, + }, + logger, + options, + }); +} diff --git a/packages/turbo-workspaces/src/commands/convert/types.ts b/packages/turbo-workspaces/src/commands/convert/types.ts new file mode 100644 index 0000000..a7b9deb --- /dev/null +++ b/packages/turbo-workspaces/src/commands/convert/types.ts @@ -0,0 +1,6 @@ +export type ConvertCommandArgument = string; +export type ConvertCommandOptions = { + dry: boolean; + force: boolean; + skipInstall: boolean; +}; diff --git a/packages/turbo-workspaces/src/commands/index.ts b/packages/turbo-workspaces/src/commands/index.ts new file mode 100644 index 0000000..423e959 --- /dev/null +++ b/packages/turbo-workspaces/src/commands/index.ts @@ -0,0 +1,8 @@ +export { default as summary } from "./summary"; +export { default as convert } from "./convert"; + +export type { SummaryCommandArgument } from "./summary/types"; +export type { + ConvertCommandArgument, + ConvertCommandOptions, +} from "./convert/types"; diff --git a/packages/turbo-workspaces/src/commands/summary/index.ts b/packages/turbo-workspaces/src/commands/summary/index.ts new file mode 100644 index 0000000..9696a14 --- /dev/null +++ b/packages/turbo-workspaces/src/commands/summary/index.ts @@ -0,0 +1,98 @@ +import inquirer from "inquirer"; +import path from "path"; +import { Logger } from "../../logger"; +import chalk from "chalk"; +import { SummaryCommandArgument } from "./types"; +import { directoryInfo } from "../../utils"; +import getWorkspaceDetails from "../../getWorkspaceDetails"; +import { Workspace } from "../../types"; + +export default async function summary(directory: SummaryCommandArgument) { + const logger = new Logger(); + logger.hero(); + + const answer = await inquirer.prompt<{ + directoryInput?: string; + }>({ + type: "input", + name: "directoryInput", + message: "Where is the root of the repo?", + 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 } = answer; + const { exists, absolute: root } = directoryInfo({ + directory: selectedDirectory, + }); + if (!exists) { + console.error(`Directory ${chalk.dim(`(${root})`)} does not exist`); + return process.exit(1); + } + + const project = await getWorkspaceDetails({ root }); + + const numWorkspaces = project.workspaceData.workspaces.length; + const hasWorkspaces = numWorkspaces > 0; + // group workspaces + const workspacesByDirectory: Record<string, Array<Workspace>> = {}; + project.workspaceData.workspaces.forEach((workspace) => { + const workspacePath = path.relative(root, workspace.paths.root); + const rootDirectory = workspacePath.split(path.sep)[0]; + if (!workspacesByDirectory[rootDirectory]) { + workspacesByDirectory[rootDirectory] = []; + } + workspacesByDirectory[rootDirectory].push(workspace); + }); + + const renderWorkspace = (w: Workspace) => { + return `${w.name} (${chalk.italic( + `./${path.relative(root, w.paths.root)}` + )})`; + }; + + const renderDirectory = ({ + number, + directory, + workspaces, + }: { + number: number; + directory: string; + workspaces: Array<Workspace>; + }) => { + logger.indented(2, `${number}. ${chalk.bold(directory)}`); + workspaces.forEach((workspace, idx) => { + logger.indented(3, `${idx + 1}. ${renderWorkspace(workspace)}`); + }); + }; + + // repo header + logger.header(`Repository Summary`); + logger.indented(1, `${chalk.underline(project.name)}:`); + // workspace manager header + logger.indented( + 1, + `Package Manager: ${chalk.bold(chalk.italic(project.packageManager))}` + ); + if (hasWorkspaces) { + // workspaces header + logger.indented(1, `Workspaces (${chalk.bold(numWorkspaces.toString())}):`); + Object.keys(workspacesByDirectory).forEach((directory, idx) => { + renderDirectory({ + number: idx + 1, + directory, + workspaces: workspacesByDirectory[directory], + }); + }); + logger.blankLine(); + } +} diff --git a/packages/turbo-workspaces/src/commands/summary/types.ts b/packages/turbo-workspaces/src/commands/summary/types.ts new file mode 100644 index 0000000..0cc5830 --- /dev/null +++ b/packages/turbo-workspaces/src/commands/summary/types.ts @@ -0,0 +1 @@ +export type SummaryCommandArgument = string; diff --git a/packages/turbo-workspaces/src/convert.ts b/packages/turbo-workspaces/src/convert.ts new file mode 100644 index 0000000..9f481c1 --- /dev/null +++ b/packages/turbo-workspaces/src/convert.ts @@ -0,0 +1,61 @@ +import chalk from "chalk"; +import managers from "./managers"; +import { Project, Options, PackageManagerDetails } from "./types"; +import install from "./install"; +import { Logger } from "./logger"; +import { ConvertError } from "./errors"; + +/* + * Convert a project using workspaces from one package manager to another. + + Steps are run in the following order: + 1. managerFrom.remove + 2. managerTo.create + 3. managerTo.convertLock + 3. install + 4. managerFrom.clean + +*/ +export async function convertProject({ + project, + to, + logger, + options, +}: { + project: Project; + to: PackageManagerDetails; + logger: Logger; + options?: Options; +}) { + logger.header( + `Converting project from ${project.packageManager} to ${to.name}.` + ); + + if (project.packageManager == to.name) { + throw new ConvertError("You are already using this package manager", { + type: "package_manager-already_in_use", + }); + } + + // remove old workspace data + await managers[project.packageManager].remove({ + project, + to, + logger, + options, + }); + + // create new workspace data + await managers[to.name].create({ project, to, logger, options }); + + logger.mainStep("Installing dependencies"); + if (!options?.skipInstall) { + await managers[to.name].convertLock({ project, logger, options }); + await install({ project, to, logger, options }); + } else { + logger.subStep(chalk.yellow("Skipping install")); + } + + logger.mainStep(`Cleaning up ${project.packageManager} workspaces`); + await managers[project.packageManager].clean({ project, logger }); +} diff --git a/packages/turbo-workspaces/src/errors.ts b/packages/turbo-workspaces/src/errors.ts new file mode 100644 index 0000000..aa2fabe --- /dev/null +++ b/packages/turbo-workspaces/src/errors.ts @@ -0,0 +1,31 @@ +export type ConvertErrorType = + // package manager general + | "package_manager-unexpected" + | "package_manager-already_in_use" + | "package_manager-unable_to_detect" + | "package_manager-unsupported_version" + // package manager specific + | "pnpm-workspace_parse_error" + // package.json + | "package_json-parse_error" + | "package_json-missing" + // other + | "invalid_directory" + | "error_removing_node_modules" + // default + | "unknown"; + +export type ConvertErrorOptions = { + type?: ConvertErrorType; +}; + +export class ConvertError extends Error { + public type: ConvertErrorType; + + constructor(message: string, opts?: ConvertErrorOptions) { + super(message); + this.name = "ConvertError"; + this.type = opts?.type ?? "unknown"; + Error.captureStackTrace(this, ConvertError); + } +} diff --git a/packages/turbo-workspaces/src/getWorkspaceDetails.ts b/packages/turbo-workspaces/src/getWorkspaceDetails.ts new file mode 100644 index 0000000..ad86a97 --- /dev/null +++ b/packages/turbo-workspaces/src/getWorkspaceDetails.ts @@ -0,0 +1,35 @@ +import { ConvertError } from "./errors"; +import managers from "./managers"; +import { Project } from "./types"; +import { directoryInfo } from "./utils"; + +export default async function getWorkspaceDetails({ + root, +}: { + root: string; +}): Promise<Project> { + const { exists, absolute: workspaceRoot } = directoryInfo({ + directory: root, + }); + if (!exists) { + throw new ConvertError( + `Could not find directory at ${workspaceRoot}. Ensure the directory exists.`, + { + type: "invalid_directory", + } + ); + } + + for (const { detect, read } of Object.values(managers)) { + if (await detect({ workspaceRoot })) { + return read({ workspaceRoot }); + } + } + + throw new ConvertError( + "Could not determine package manager. Add `packageManager` to `package.json` or ensure a lockfile is present.", + { + type: "package_manager-unable_to_detect", + } + ); +} diff --git a/packages/turbo-workspaces/src/index.ts b/packages/turbo-workspaces/src/index.ts new file mode 100644 index 0000000..c7fc6b8 --- /dev/null +++ b/packages/turbo-workspaces/src/index.ts @@ -0,0 +1,58 @@ +import { getAvailablePackageManagers } from "@turbo/utils"; +import getWorkspaceDetails from "./getWorkspaceDetails"; +import { convertProject } from "./convert"; +import { Logger } from "./logger"; +import install, { getPackageManagerMeta } from "./install"; +import { ConvertError } from "./errors"; +import MANAGERS from "./managers"; + +import type { + PackageManager, + Options, + InstallArgs, + Workspace, + Project, +} from "./types"; +import type { ConvertErrorType } from "./errors"; + +async function convert({ + root, + to, + options, +}: { + root: string; + to: PackageManager; + options?: Options; +}) { + const logger = new Logger({ ...options, interactive: false }); + const [project, availablePackageManagers] = await Promise.all([ + getWorkspaceDetails({ root }), + getAvailablePackageManagers(), + ]); + await convertProject({ + project, + to: { + name: to, + version: availablePackageManagers[to].version as PackageManager, + }, + logger, + options, + }); +} + +export type { + PackageManager, + Options, + InstallArgs, + Workspace, + Project, + ConvertErrorType, +}; +export { + convert, + getWorkspaceDetails, + install, + MANAGERS, + getPackageManagerMeta, + ConvertError, +}; diff --git a/packages/turbo-workspaces/src/install.ts b/packages/turbo-workspaces/src/install.ts new file mode 100644 index 0000000..ad5f741 --- /dev/null +++ b/packages/turbo-workspaces/src/install.ts @@ -0,0 +1,125 @@ +import execa from "execa"; +import ora from "ora"; +import { satisfies } from "semver"; +import { ConvertError } from "./errors"; +import { Logger } from "./logger"; +import { + PackageManager, + PackageManagerDetails, + PackageManagerInstallDetails, + InstallArgs, +} from "./types"; + +export const PACKAGE_MANAGERS: Record< + PackageManager, + Array<PackageManagerInstallDetails> +> = { + npm: [ + { + name: "npm", + template: "npm", + command: "npm", + installArgs: ["install"], + version: "latest", + executable: "npx", + semver: "*", + default: true, + }, + ], + pnpm: [ + { + name: "pnpm6", + template: "pnpm", + command: "pnpm", + installArgs: ["install"], + version: "latest-6", + executable: "pnpx", + semver: "6.x", + }, + { + name: "pnpm", + template: "pnpm", + command: "pnpm", + installArgs: ["install"], + version: "latest", + executable: "pnpm dlx", + semver: ">=7", + default: true, + }, + ], + yarn: [ + { + name: "yarn", + template: "yarn", + command: "yarn", + installArgs: ["install"], + version: "1.x", + executable: "npx", + semver: "<2", + default: true, + }, + { + name: "berry", + template: "berry", + command: "yarn", + installArgs: ["install", "--no-immutable"], + version: "stable", + executable: "yarn dlx", + semver: ">=2", + }, + ], +}; + +export function getPackageManagerMeta(packageManager: PackageManagerDetails) { + const { version, name } = packageManager; + if (version) { + return PACKAGE_MANAGERS[name].find((manager) => + satisfies(version, manager.semver) + ); + } else { + return PACKAGE_MANAGERS[name].find((manager) => { + return manager.default; + }); + } +} + +export default async function install(args: InstallArgs) { + const { to, logger, options } = args; + + const installLogger = logger ?? new Logger(options); + const packageManager = getPackageManagerMeta(to); + + if (!packageManager) { + throw new ConvertError("Unsupported package manager version.", { + type: "package_manager-unsupported_version", + }); + } + + installLogger.subStep( + `running "${packageManager.command} ${packageManager.installArgs}"` + ); + if (!options?.dry) { + let spinner; + if (installLogger?.interactive) { + spinner = ora({ + text: "installing dependencies...", + spinner: { + frames: installLogger.installerFrames(), + }, + }).start(); + } + + try { + await execa(packageManager.command, packageManager.installArgs, { + cwd: args.project.paths.root, + }); + if (spinner) { + spinner.stop(); + } + installLogger.subStep(`dependencies installed`); + } catch (err) { + installLogger.subStepFailure(`failed to install dependencies`); + throw err; + } + } +} diff --git a/packages/turbo-workspaces/src/logger.ts b/packages/turbo-workspaces/src/logger.ts new file mode 100644 index 0000000..222c89a --- /dev/null +++ b/packages/turbo-workspaces/src/logger.ts @@ -0,0 +1,109 @@ +import chalk from "chalk"; +import gradient from "gradient-string"; + +const INDENTATION = 2; + +export class Logger { + interactive: boolean; + dry: boolean; + step: number; + + constructor({ + interactive, + dry, + }: { interactive?: boolean; dry?: boolean } = {}) { + this.interactive = interactive ?? true; + this.dry = dry ?? false; + this.step = 1; + } + + logger(...args: any[]) { + if (this.interactive) { + console.log(...args); + } + } + + indented(level: number, ...args: any[]) { + this.logger(" ".repeat(INDENTATION * level), ...args); + } + + header(title: string) { + this.blankLine(); + this.logger(chalk.bold(title)); + } + + installerFrames() { + const prefix = `${" ".repeat(INDENTATION)} - ${ + this.dry ? chalk.yellow("SKIPPED | ") : chalk.green("OK | ") + }`; + return [`${prefix} `, `${prefix}> `, `${prefix}>> `, `${prefix}>>>`]; + } + + gradient(text: string | number) { + const turboGradient = gradient("#0099F7", "#F11712"); + return turboGradient(text.toString()); + } + + hero() { + this.logger(chalk.bold(this.gradient(`\n>>> TURBOREPO\n`))); + } + + info(...args: any[]) { + this.logger(...args); + } + + mainStep(title: string) { + this.blankLine(); + this.logger(`${this.step}. ${chalk.underline(title)}`); + this.step += 1; + } + + subStep(...args: any[]) { + this.logger( + " ".repeat(INDENTATION), + `-`, + this.dry ? chalk.yellow("SKIPPED |") : chalk.green("OK |"), + ...args + ); + } + + subStepFailure(...args: any[]) { + this.logger(" ".repeat(INDENTATION), `-`, chalk.red("ERROR |"), ...args); + } + + rootHeader() { + this.blankLine(); + this.indented(2, "Root:"); + } + + rootStep(...args: any[]) { + this.logger( + " ".repeat(INDENTATION * 3), + `-`, + this.dry ? chalk.yellow("SKIPPED |") : chalk.green("OK |"), + ...args + ); + } + + workspaceHeader() { + this.blankLine(); + this.indented(2, "Workspaces:"); + } + + workspaceStep(...args: any[]) { + this.logger( + " ".repeat(INDENTATION * 3), + `-`, + this.dry ? chalk.yellow("SKIPPED |") : chalk.green("OK |"), + ...args + ); + } + + blankLine() { + this.logger(); + } + + error(...args: any[]) { + console.error(...args); + } +} diff --git a/packages/turbo-workspaces/src/managers/index.ts b/packages/turbo-workspaces/src/managers/index.ts new file mode 100644 index 0000000..e026aed --- /dev/null +++ b/packages/turbo-workspaces/src/managers/index.ts @@ -0,0 +1,11 @@ +import pnpm from "./pnpm"; +import npm from "./npm"; +import yarn from "./yarn"; +import { ManagerHandler, PackageManager } from "../types"; + +const MANAGERS: Record<PackageManager, ManagerHandler> = { + pnpm, + yarn, + npm, +}; +export default MANAGERS; diff --git a/packages/turbo-workspaces/src/managers/npm.ts b/packages/turbo-workspaces/src/managers/npm.ts new file mode 100644 index 0000000..26fd76a --- /dev/null +++ b/packages/turbo-workspaces/src/managers/npm.ts @@ -0,0 +1,223 @@ +import fs from "fs-extra"; +import path from "path"; +import { ConvertError } from "../errors"; +import updateDependencies from "../updateDependencies"; +import { + DetectArgs, + ReadArgs, + CreateArgs, + RemoveArgs, + CleanArgs, + Project, + ConvertArgs, + ManagerHandler, +} from "../types"; +import { + getMainStep, + getWorkspaceInfo, + getPackageJson, + expandWorkspaces, + getWorkspacePackageManager, + expandPaths, +} from "../utils"; + +/** + * Check if a given project is using npm workspaces + * Verify by checking for the existence of: + * 1. package-lock.json + * 2. packageManager field in package.json + */ +async function detect(args: DetectArgs): Promise<boolean> { + const lockFile = path.join(args.workspaceRoot, "package-lock.json"); + const packageManager = getWorkspacePackageManager({ + workspaceRoot: args.workspaceRoot, + }); + return fs.existsSync(lockFile) || packageManager === "npm"; +} + +/** + Read workspace data from npm workspaces into generic format +*/ +async function read(args: ReadArgs): Promise<Project> { + const isNpm = await detect(args); + if (!isNpm) { + throw new ConvertError("Not an npm project", { + type: "package_manager-unexpected", + }); + } + + const packageJson = getPackageJson(args); + const { name, description } = getWorkspaceInfo(args); + return { + name, + description, + packageManager: "npm", + paths: expandPaths({ + root: args.workspaceRoot, + lockFile: "package-lock.json", + }), + workspaceData: { + globs: packageJson.workspaces || [], + workspaces: expandWorkspaces({ + workspaceGlobs: packageJson.workspaces, + ...args, + }), + }, + }; +} + +/** + * Create npm workspaces from generic format + * + * Creating npm workspaces involves: + * 1. Adding the workspaces field in package.json + * 2. Setting the packageManager field in package.json + * 3. Updating all workspace package.json dependencies to ensure correct format + */ +async function create(args: CreateArgs): Promise<void> { + const { project, options, to, logger } = args; + const hasWorkspaces = project.workspaceData.globs.length > 0; + + logger.mainStep( + getMainStep({ packageManager: "npm", action: "create", project }) + ); + const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); + logger.rootHeader(); + + // package manager + logger.rootStep( + `adding "packageManager" field to ${path.relative( + project.paths.root, + project.paths.packageJson + )}` + ); + packageJson.packageManager = `${to.name}@${to.version}`; + + if (hasWorkspaces) { + // workspaces field + logger.rootStep( + `adding "workspaces" field to ${path.relative( + project.paths.root, + project.paths.packageJson + )}` + ); + packageJson.workspaces = project.workspaceData.globs; + + // write package.json here instead of deferring to avoid negating the changes made by updateDependencies + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + } + + // root dependencies + updateDependencies({ + workspace: { name: "root", paths: project.paths }, + project, + to, + logger, + options, + }); + + // workspace dependencies + logger.workspaceHeader(); + project.workspaceData.workspaces.forEach((workspace) => + updateDependencies({ workspace, project, to, logger, options }) + ); + } else { + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + } + } +} + +/** + * Remove npm workspace data + * Removing npm workspaces involves: + * 1. Removing the workspaces field from package.json + * 2. Removing the node_modules directory + */ +async function remove(args: RemoveArgs): Promise<void> { + const { project, logger, options } = args; + const hasWorkspaces = project.workspaceData.globs.length > 0; + + logger.mainStep( + getMainStep({ packageManager: "npm", action: "remove", project }) + ); + const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); + + if (hasWorkspaces) { + logger.subStep( + `removing "workspaces" field in ${project.name} root "package.json"` + ); + delete packageJson.workspaces; + } + + logger.subStep( + `removing "packageManager" field in ${project.name} root "package.json"` + ); + delete packageJson.packageManager; + + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + + // collect all workspace node_modules directories + const allModulesDirs = [ + project.paths.nodeModules, + ...project.workspaceData.workspaces.map((w) => w.paths.nodeModules), + ]; + try { + logger.subStep(`removing "node_modules"`); + await Promise.all( + allModulesDirs.map((dir) => + fs.rm(dir, { recursive: true, force: true }) + ) + ); + } catch (err) { + throw new ConvertError("Failed to remove node_modules", { + type: "error_removing_node_modules", + }); + } + } +} + +/** + * Clean is called post install, and is used to clean up any files + * from this package manager that were needed for install, + * but not required after migration + */ +async function clean(args: CleanArgs): Promise<void> { + const { project, logger, options } = args; + + logger.subStep( + `removing ${path.relative(project.paths.root, project.paths.lockfile)}` + ); + if (!options?.dry) { + fs.rmSync(project.paths.lockfile, { force: true }); + } +} + +/** + * Attempts to convert an existing, non npm lockfile to an npm lockfile + * + * If this is not possible, the non npm lockfile is removed + */ +async function convertLock(args: ConvertArgs): Promise<void> { + const { project, options } = args; + + if (project.packageManager !== "npm") { + // remove the lockfile + if (!options?.dry) { + fs.rmSync(project.paths.lockfile, { force: true }); + } + } +} + +const npm: ManagerHandler = { + detect, + read, + create, + remove, + clean, + convertLock, +}; + +export default npm; diff --git a/packages/turbo-workspaces/src/managers/pnpm.ts b/packages/turbo-workspaces/src/managers/pnpm.ts new file mode 100644 index 0000000..747e578 --- /dev/null +++ b/packages/turbo-workspaces/src/managers/pnpm.ts @@ -0,0 +1,238 @@ +import fs from "fs-extra"; +import path from "path"; +import execa from "execa"; +import { ConvertError } from "../errors"; +import updateDependencies from "../updateDependencies"; +import { + DetectArgs, + ReadArgs, + CreateArgs, + RemoveArgs, + ConvertArgs, + CleanArgs, + Project, + ManagerHandler, +} from "../types"; +import { + getMainStep, + expandPaths, + getWorkspaceInfo, + expandWorkspaces, + getPnpmWorkspaces, + getPackageJson, + getWorkspacePackageManager, +} from "../utils"; + +/** + * Check if a given project is using pnpm workspaces + * Verify by checking for the existence of: + * 1. pnpm-workspace.yaml + * 2. pnpm-workspace.yaml + */ +async function detect(args: DetectArgs): Promise<boolean> { + const lockFile = path.join(args.workspaceRoot, "pnpm-lock.yaml"); + const workspaceFile = path.join(args.workspaceRoot, "pnpm-workspace.yaml"); + const packageManager = getWorkspacePackageManager({ + workspaceRoot: args.workspaceRoot, + }); + return ( + fs.existsSync(lockFile) || + fs.existsSync(workspaceFile) || + packageManager === "pnpm" + ); +} + +/** + Read workspace data from pnpm workspaces into generic format +*/ +async function read(args: ReadArgs): Promise<Project> { + const isPnpm = await detect(args); + if (!isPnpm) { + throw new ConvertError("Not a pnpm project", { + type: "package_manager-unexpected", + }); + } + + const { name, description } = getWorkspaceInfo(args); + return { + name, + description, + packageManager: "pnpm", + paths: expandPaths({ + root: args.workspaceRoot, + lockFile: "pnpm-lock.yaml", + workspaceConfig: "pnpm-workspace.yaml", + }), + workspaceData: { + globs: getPnpmWorkspaces(args), + workspaces: expandWorkspaces({ + workspaceGlobs: getPnpmWorkspaces(args), + ...args, + }), + }, + }; +} + +/** + * Create pnpm workspaces from generic format + * + * Creating pnpm workspaces involves: + * 1. Create pnpm-workspace.yaml + * 2. Setting the packageManager field in package.json + * 3. Updating all workspace package.json dependencies to ensure correct format + */ +async function create(args: CreateArgs): Promise<void> { + const { project, to, logger, options } = args; + const hasWorkspaces = project.workspaceData.globs.length > 0; + + logger.mainStep( + getMainStep({ action: "create", packageManager: "pnpm", project }) + ); + + const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); + logger.rootHeader(); + packageJson.packageManager = `${to.name}@${to.version}`; + logger.rootStep( + `adding "packageManager" field to ${project.name} root "package.json"` + ); + + // write the changes + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + + if (hasWorkspaces) { + logger.rootStep(`adding "pnpm-workspace.yaml"`); + fs.writeFileSync( + path.join(project.paths.root, "pnpm-workspace.yaml"), + `packages:\n${project.workspaceData.globs + .map((w) => ` - "${w}"`) + .join("\n")}` + ); + } + } + + if (hasWorkspaces) { + // root dependencies + updateDependencies({ + workspace: { name: "root", paths: project.paths }, + project, + to, + logger, + options, + }); + + // workspace dependencies + logger.workspaceHeader(); + project.workspaceData.workspaces.forEach((workspace) => + updateDependencies({ workspace, project, to, logger, options }) + ); + } +} + +/** + * Remove pnpm workspace data + * + * Cleaning up from pnpm involves: + * 1. Removing the pnpm-workspace.yaml file + * 2. Removing the pnpm-lock.yaml file + * 3. Removing the node_modules directory + */ +async function remove(args: RemoveArgs): Promise<void> { + const { project, logger, options } = args; + const hasWorkspaces = project.workspaceData.globs.length > 0; + + logger.mainStep( + getMainStep({ action: "remove", packageManager: "pnpm", project }) + ); + const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); + + if (project.paths.workspaceConfig && hasWorkspaces) { + logger.subStep(`removing "pnpm-workspace.yaml"`); + if (!options?.dry) { + fs.rmSync(project.paths.workspaceConfig, { force: true }); + } + } + + logger.subStep( + `removing "packageManager" field in ${project.name} root "package.json"` + ); + delete packageJson.packageManager; + + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + + // collect all workspace node_modules directories + const allModulesDirs = [ + project.paths.nodeModules, + ...project.workspaceData.workspaces.map((w) => w.paths.nodeModules), + ]; + + try { + logger.subStep(`removing "node_modules"`); + await Promise.all( + allModulesDirs.map((dir) => + fs.rm(dir, { recursive: true, force: true }) + ) + ); + } catch (err) { + throw new ConvertError("Failed to remove node_modules", { + type: "error_removing_node_modules", + }); + } + } +} + +/** + * Clean is called post install, and is used to clean up any files + * from this package manager that were needed for install, + * but not required after migration + */ +async function clean(args: CleanArgs): Promise<void> { + const { project, logger, options } = args; + + logger.subStep( + `removing ${path.relative(project.paths.root, project.paths.lockfile)}` + ); + if (!options?.dry) { + fs.rmSync(project.paths.lockfile, { force: true }); + } +} + +/** + * Attempts to convert an existing, non pnpm lockfile to a pnpm lockfile + * + * If this is not possible, the non pnpm lockfile is removed + */ +async function convertLock(args: ConvertArgs): Promise<void> { + const { project, logger, options } = args; + + if (project.packageManager !== "pnpm") { + logger.subStep( + `converting ${path.relative( + project.paths.root, + project.paths.lockfile + )} to pnpm-lock.yaml` + ); + if (!options?.dry && fs.existsSync(project.paths.lockfile)) { + try { + await execa("pnpm", ["import"], { + stdio: "ignore", + cwd: project.paths.root, + }); + } finally { + fs.rmSync(project.paths.lockfile, { force: true }); + } + } + } +} + +const pnpm: ManagerHandler = { + detect, + read, + create, + remove, + clean, + convertLock, +}; + +export default pnpm; diff --git a/packages/turbo-workspaces/src/managers/yarn.ts b/packages/turbo-workspaces/src/managers/yarn.ts new file mode 100644 index 0000000..9bef53f --- /dev/null +++ b/packages/turbo-workspaces/src/managers/yarn.ts @@ -0,0 +1,222 @@ +import fs from "fs-extra"; +import path from "path"; +import { ConvertError } from "../errors"; +import updateDependencies from "../updateDependencies"; +import { + DetectArgs, + ReadArgs, + CreateArgs, + RemoveArgs, + ConvertArgs, + CleanArgs, + Project, +} from "../types"; +import { + getMainStep, + getWorkspaceInfo, + getPackageJson, + expandPaths, + expandWorkspaces, + getWorkspacePackageManager, +} from "../utils"; + +/** + * Check if a given project is using yarn workspaces + * Verify by checking for the existence of: + * 1. yarn.lock + * 2. packageManager field in package.json + */ +async function detect(args: DetectArgs): Promise<boolean> { + const lockFile = path.join(args.workspaceRoot, "yarn.lock"); + const packageManager = getWorkspacePackageManager({ + workspaceRoot: args.workspaceRoot, + }); + return fs.existsSync(lockFile) || packageManager === "yarn"; +} + +/** + Read workspace data from yarn workspaces into generic format +*/ +async function read(args: ReadArgs): Promise<Project> { + const isYarn = await detect(args); + if (!isYarn) { + throw new ConvertError("Not a yarn project", { + type: "package_manager-unexpected", + }); + } + + const packageJson = getPackageJson(args); + const { name, description } = getWorkspaceInfo(args); + return { + name, + description, + packageManager: "yarn", + paths: expandPaths({ + root: args.workspaceRoot, + lockFile: "yarn.lock", + }), + workspaceData: { + globs: packageJson.workspaces || [], + workspaces: expandWorkspaces({ + workspaceGlobs: packageJson.workspaces, + ...args, + }), + }, + }; +} + +/** + * Create yarn workspaces from generic format + * + * Creating yarn workspaces involves: + * 1. Adding the workspaces field in package.json + * 2. Setting the packageManager field in package.json + * 3. Updating all workspace package.json dependencies to ensure correct format + */ +async function create(args: CreateArgs): Promise<void> { + const { project, to, logger, options } = args; + const hasWorkspaces = project.workspaceData.globs.length > 0; + + logger.mainStep( + getMainStep({ packageManager: "yarn", action: "create", project }) + ); + const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); + logger.rootHeader(); + + // package manager + logger.rootStep( + `adding "packageManager" field to ${path.relative( + project.paths.root, + project.paths.packageJson + )}` + ); + packageJson.packageManager = `${to.name}@${to.version}`; + + if (hasWorkspaces) { + // workspaces field + logger.rootStep( + `adding "workspaces" field to ${path.relative( + project.paths.root, + project.paths.packageJson + )}` + ); + packageJson.workspaces = project.workspaceData.globs; + + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + } + + // root dependencies + updateDependencies({ + workspace: { name: "root", paths: project.paths }, + project, + to, + logger, + options, + }); + + // workspace dependencies + logger.workspaceHeader(); + project.workspaceData.workspaces.forEach((workspace) => + updateDependencies({ workspace, project, to, logger, options }) + ); + } else { + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + } + } +} + +/** + * Remove yarn workspace data + * + * Removing yarn workspaces involves: + * 1. Removing the workspaces field from package.json + * 2. Removing the node_modules directory + */ +async function remove(args: RemoveArgs): Promise<void> { + const { project, logger, options } = args; + const hasWorkspaces = project.workspaceData.globs.length > 0; + + logger.mainStep( + getMainStep({ packageManager: "yarn", action: "remove", project }) + ); + const packageJson = getPackageJson({ workspaceRoot: project.paths.root }); + + if (hasWorkspaces) { + logger.subStep( + `removing "workspaces" field in ${project.name} root "package.json"` + ); + delete packageJson.workspaces; + } + + logger.subStep( + `removing "packageManager" field in ${project.name} root "package.json"` + ); + delete packageJson.packageManager; + + if (!options?.dry) { + fs.writeJSONSync(project.paths.packageJson, packageJson, { spaces: 2 }); + + // collect all workspace node_modules directories + const allModulesDirs = [ + project.paths.nodeModules, + ...project.workspaceData.workspaces.map((w) => w.paths.nodeModules), + ]; + try { + logger.subStep(`removing "node_modules"`); + await Promise.all( + allModulesDirs.map((dir) => + fs.rm(dir, { recursive: true, force: true }) + ) + ); + } catch (err) { + throw new ConvertError("Failed to remove node_modules", { + type: "error_removing_node_modules", + }); + } + } +} + +/** + * Clean is called post install, and is used to clean up any files + * from this package manager that were needed for install, + * but not required after migration + */ +async function clean(args: CleanArgs): Promise<void> { + const { project, logger, options } = args; + + logger.subStep( + `removing ${path.relative(project.paths.root, project.paths.lockfile)}` + ); + if (!options?.dry) { + fs.rmSync(project.paths.lockfile, { force: true }); + } +} + +/** + * Attempts to convert an existing, non yarn lockfile to a yarn lockfile + * + * If this is not possible, the non yarn lockfile is removed + */ +async function convertLock(args: ConvertArgs): Promise<void> { + const { project, options } = args; + + if (project.packageManager !== "yarn") { + // remove the lockfile + if (!options?.dry) { + fs.rmSync(project.paths.lockfile, { force: true }); + } + } +} + +const yarn = { + detect, + read, + create, + remove, + clean, + convertLock, +}; + +export default yarn; diff --git a/packages/turbo-workspaces/src/types.ts b/packages/turbo-workspaces/src/types.ts new file mode 100644 index 0000000..9240f09 --- /dev/null +++ b/packages/turbo-workspaces/src/types.ts @@ -0,0 +1,127 @@ +import { Logger } from "./logger"; + +export type PackageManager = "npm" | "pnpm" | "yarn"; +export type PackageManagerDetails = { + name: PackageManager; + version?: string; +}; + +export type Project = { + name: string; + description?: string; + packageManager: PackageManager; + paths: { + root: string; + packageJson: string; + lockfile: string; + nodeModules: string; + // pnpm workspace config file + workspaceConfig?: string; + }; + workspaceData: { + globs: Array<string>; + workspaces: Array<Workspace>; + }; +}; + +export type Workspace = { + name: string; + description?: string; + paths: { + root: string; + packageJson: string; + nodeModules: string; + }; +}; + +export type WorkspaceInfo = Pick<Workspace, "name" | "description">; + +export type DependencyList = Record<string, string>; + +export type PackageJsonDependencies = { + dependencies?: DependencyList; + devDependencies?: DependencyList; + peerDependencies?: DependencyList; + optionalDependencies?: DependencyList; +}; + +export type PackageJson = PackageJsonDependencies & { + name?: string; + description?: string; + workspaces?: Array<string>; + packageManager?: string; +}; + +export type DetectArgs = { + workspaceRoot: string; +}; + +export type ReadArgs = { + workspaceRoot: string; +}; + +export type CreateArgs = { + project: Project; + to: PackageManagerDetails; + logger: Logger; + options?: Options; +}; + +export type RemoveArgs = { + project: Project; + to: PackageManagerDetails; + logger: Logger; + options?: Options; +}; + +export type CleanArgs = { + project: Project; + logger: Logger; + options?: Options; +}; + +export type ConvertArgs = { + project: Project; + logger: Logger; + options?: Options; +}; + +export type InstallArgs = { + project: Project; + to: PackageManagerDetails; + logger?: Logger; + options?: Options; +}; + +export type Options = { + dry?: boolean; + skipInstall?: boolean; + interactive?: boolean; +}; + +export type PackageManagerInstallDetails = { + name: string; + template: string; + command: PackageManager; + installArgs: string[]; + version: string; + executable: string; + semver: string; + default?: boolean; +}; + +export type ManagerDetect = (args: DetectArgs) => Promise<boolean>; +export type ManagerRead = (args: ReadArgs) => Promise<Project>; +export type ManagerCreate = (args: CreateArgs) => Promise<void>; +export type ManagerRemove = (args: RemoveArgs) => Promise<void>; +export type ManagerClean = (args: CleanArgs) => Promise<void>; +export type ManagerConvert = (args: ConvertArgs) => Promise<void>; + +export type ManagerHandler = { + detect: ManagerDetect; + read: ManagerRead; + create: ManagerCreate; + remove: ManagerRemove; + clean: ManagerClean; + convertLock: ManagerConvert; +}; diff --git a/packages/turbo-workspaces/src/updateDependencies.ts b/packages/turbo-workspaces/src/updateDependencies.ts new file mode 100644 index 0000000..5461ed0 --- /dev/null +++ b/packages/turbo-workspaces/src/updateDependencies.ts @@ -0,0 +1,135 @@ +import fs from "fs-extra"; +import chalk from "chalk"; +import path from "path"; +import { + Project, + Workspace, + DependencyList, + PackageManagerDetails, + Options, + PackageJsonDependencies, +} from "./types"; +import { Logger } from "./logger"; +import { getPackageJson } from "./utils"; + +function updateDependencyList({ + dependencyList, + project, + to, +}: { + dependencyList: DependencyList; + project: Project; + to: PackageManagerDetails; +}): { dependencyList: DependencyList; updated: Array<string> } { + const updated: Array<string> = []; + project.workspaceData.workspaces.forEach((workspace) => { + const { name } = workspace; + if (dependencyList[name]) { + const workspaceVersion = dependencyList[name]; + const version = workspaceVersion.startsWith("workspace:") + ? workspaceVersion.slice("workspace:".length) + : workspaceVersion; + dependencyList[name] = + to.name === "pnpm" ? `workspace:${version}` : version; + updated.push(name); + } + }); + + return { dependencyList, updated }; +} + +export default function updateDependencies({ + project, + workspace, + to, + logger, + options, +}: { + workspace: Workspace; + project: Project; + to: PackageManagerDetails; + logger: Logger; + options?: Options; +}): void { + // this step isn't required if moving between yarn / npm + if ( + ["yarn", "npm"].includes(to.name) && + ["yarn", "npm"].includes(project.packageManager) + ) { + return; + } + + // update all dependencies + const workspacePackageJson = getPackageJson({ + workspaceRoot: workspace.paths.root, + }); + + // collect stats as we go for consolidated output at the end + const stats: Record<keyof PackageJsonDependencies, Array<string>> = { + dependencies: [], + devDependencies: [], + peerDependencies: [], + optionalDependencies: [], + }; + + const allDependencyKeys: Array<keyof PackageJsonDependencies> = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]; + + allDependencyKeys.forEach((depKey) => { + const depList = workspacePackageJson[depKey]; + if (depList) { + const { updated, dependencyList } = updateDependencyList({ + dependencyList: depList, + project, + to, + }); + + workspacePackageJson[depKey] = dependencyList; + stats[depKey] = updated; + } + }); + + const toLog = (key: keyof PackageJsonDependencies) => { + const total = stats[key].length; + if (total > 0) { + return `${chalk.green(total.toString())} ${key}`; + } + return undefined; + }; + + const allChanges = allDependencyKeys.map(toLog).filter(Boolean); + const workspaceLocation = `./${path.relative( + project.paths.root, + workspace.paths.packageJson + )}`; + if (allChanges.length >= 1) { + let logLine = "updating"; + allChanges.forEach((stat, idx) => { + if (allChanges.length === 1) { + logLine += ` ${stat} in ${workspaceLocation}`; + } else { + if (idx === allChanges.length - 1) { + logLine += `and ${stat} in ${workspaceLocation}`; + } else { + logLine += ` ${stat}, `; + } + } + }); + + logger.workspaceStep(logLine); + } else { + logger.workspaceStep( + `no workspace dependencies found in ${workspaceLocation}` + ); + } + + if (!options?.dry) { + fs.writeJSONSync(workspace.paths.packageJson, workspacePackageJson, { + spaces: 2, + }); + } +} diff --git a/packages/turbo-workspaces/src/utils.ts b/packages/turbo-workspaces/src/utils.ts new file mode 100644 index 0000000..8290203 --- /dev/null +++ b/packages/turbo-workspaces/src/utils.ts @@ -0,0 +1,197 @@ +import fs from "fs-extra"; +import path from "path"; +import glob from "fast-glob"; +import yaml from "js-yaml"; +import { + PackageJson, + PackageManager, + Project, + Workspace, + WorkspaceInfo, +} from "./types"; +import { ConvertError } from "./errors"; + +// adapted from https://github.com/nodejs/corepack/blob/cae770694e62f15fed33dd8023649d77d96023c1/sources/specUtils.ts#L14 +const PACKAGE_MANAGER_REGEX = /^(?!_)(.+)@(.+)$/; + +function getPackageJson({ + workspaceRoot, +}: { + workspaceRoot: string; +}): PackageJson { + const packageJsonPath = path.join(workspaceRoot, "package.json"); + try { + return fs.readJsonSync(packageJsonPath, "utf8"); + } catch (err) { + if (err && typeof err === "object" && "code" in err) { + if (err.code === "ENOENT") { + throw new ConvertError(`no "package.json" found at ${workspaceRoot}`, { + type: "package_json-missing", + }); + } + if (err.code === "EJSONPARSE") { + throw new ConvertError( + `failed to parse "package.json" at ${workspaceRoot}`, + { + type: "package_json-parse_error", + } + ); + } + } + throw new Error( + `unexpected error reading "package.json" at ${workspaceRoot}` + ); + } +} + +function getWorkspacePackageManager({ + workspaceRoot, +}: { + workspaceRoot: string; +}): string | undefined { + const { packageManager } = getPackageJson({ workspaceRoot }); + if (packageManager) { + try { + const match = packageManager.match(PACKAGE_MANAGER_REGEX); + if (match) { + const [_, manager] = match; + return manager; + } + } catch (err) { + // this won't always exist. + } + } + return undefined; +} + +function getWorkspaceInfo({ + workspaceRoot, +}: { + workspaceRoot: string; +}): WorkspaceInfo { + const packageJson = getPackageJson({ workspaceRoot }); + const workspaceDirectory = path.basename(workspaceRoot); + + const { name = workspaceDirectory, description } = packageJson; + + return { + name, + description, + }; +} + +function getPnpmWorkspaces({ + workspaceRoot, +}: { + workspaceRoot: string; +}): Array<string> { + const workspaceFile = path.join(workspaceRoot, "pnpm-workspace.yaml"); + if (fs.existsSync(workspaceFile)) { + try { + const workspaceConfig = yaml.load(fs.readFileSync(workspaceFile, "utf8")); + // validate it's the type we expect + if ( + workspaceConfig instanceof Object && + "packages" in workspaceConfig && + Array.isArray(workspaceConfig.packages) + ) { + return workspaceConfig.packages as Array<string>; + } + } catch (err) { + throw new ConvertError(`failed to parse ${workspaceFile}`, { + type: "pnpm-workspace_parse_error", + }); + } + } + + return []; +} + +function expandPaths({ + root, + lockFile, + workspaceConfig, +}: { + root: string; + lockFile: string; + workspaceConfig?: string; +}) { + const fromRoot = (p: string) => path.join(root, p); + const paths: Project["paths"] = { + root, + lockfile: fromRoot(lockFile), + packageJson: fromRoot("package.json"), + nodeModules: fromRoot("node_modules"), + }; + + if (workspaceConfig) { + paths.workspaceConfig = fromRoot(workspaceConfig); + } + + return paths; +} + +function expandWorkspaces({ + workspaceRoot, + workspaceGlobs, +}: { + workspaceRoot: string; + workspaceGlobs?: string[]; +}): Array<Workspace> { + if (!workspaceGlobs) { + return []; + } + return workspaceGlobs + .flatMap((workspaceGlob) => { + const workspacePackageJsonGlob = `${workspaceGlob}/package.json`; + return glob.sync(workspacePackageJsonGlob, { + onlyFiles: true, + absolute: true, + cwd: workspaceRoot, + }); + }) + .map((workspacePackageJson) => { + const workspaceRoot = path.dirname(workspacePackageJson); + const { name, description } = getWorkspaceInfo({ workspaceRoot }); + return { + name, + description, + paths: { + root: workspaceRoot, + packageJson: workspacePackageJson, + nodeModules: path.join(workspaceRoot, "node_modules"), + }, + }; + }); +} + +function directoryInfo({ directory }: { directory: string }) { + const dir = path.resolve(process.cwd(), directory); + return { exists: fs.existsSync(dir), absolute: dir }; +} + +function getMainStep({ + packageManager, + action, + project, +}: { + packageManager: PackageManager; + action: "create" | "remove"; + project: Project; +}) { + const hasWorkspaces = project.workspaceData.globs.length > 0; + return `${action === "remove" ? "Removing" : "Adding"} ${packageManager} ${ + hasWorkspaces ? "workspaces" : "" + } ${action === "remove" ? "from" : "to"} ${project.name}`; +} + +export { + getPackageJson, + getWorkspacePackageManager, + getWorkspaceInfo, + expandPaths, + expandWorkspaces, + getPnpmWorkspaces, + directoryInfo, + getMainStep, +}; |
