diff --git a/.eslintignore b/.eslintignore index 1f7a6116453b..23f93a529131 100644 --- a/.eslintignore +++ b/.eslintignore @@ -37,6 +37,7 @@ packages/components/src/globals/scss/vendor/** # Upgrade **/__testfixtures__/** +packages/upgrade/cli.js # React **/storybook-static/** diff --git a/.prettierignore b/.prettierignore index a1a342817af0..2a7e7f16c959 100644 --- a/.prettierignore +++ b/.prettierignore @@ -58,3 +58,6 @@ packages/icons-react/next/** # Nextjs .next + +# Upgrade +packages/upgrade/cli.js diff --git a/.yarn/cache/esbuild-darwin-64-npm-0.14.10-2ec5e23e10-8.zip b/.yarn/cache/esbuild-darwin-64-npm-0.14.10-2ec5e23e10-8.zip new file mode 100644 index 000000000000..1f55057ea5b9 Binary files /dev/null and b/.yarn/cache/esbuild-darwin-64-npm-0.14.10-2ec5e23e10-8.zip differ diff --git a/.yarn/cache/esbuild-linux-64-npm-0.14.10-e622a86d7d-8.zip b/.yarn/cache/esbuild-linux-64-npm-0.14.10-e622a86d7d-8.zip new file mode 100644 index 000000000000..ccaa0f1f797c Binary files /dev/null and b/.yarn/cache/esbuild-linux-64-npm-0.14.10-e622a86d7d-8.zip differ diff --git a/.yarn/cache/esbuild-npm-0.14.10-7f73f182c8-0e42a74afa.zip b/.yarn/cache/esbuild-npm-0.14.10-7f73f182c8-0e42a74afa.zip new file mode 100644 index 000000000000..9f8c15842a4f Binary files /dev/null and b/.yarn/cache/esbuild-npm-0.14.10-7f73f182c8-0e42a74afa.zip differ diff --git a/.yarn/cache/esbuild-windows-64-npm-0.14.10-d897c35251-8.zip b/.yarn/cache/esbuild-windows-64-npm-0.14.10-d897c35251-8.zip new file mode 100644 index 000000000000..b27197246d09 Binary files /dev/null and b/.yarn/cache/esbuild-windows-64-npm-0.14.10-d897c35251-8.zip differ diff --git a/config/jest-config-carbon/index.js b/config/jest-config-carbon/index.js index 57858a2a1bae..94d711a49cf7 100644 --- a/config/jest-config-carbon/index.js +++ b/config/jest-config-carbon/index.js @@ -20,7 +20,7 @@ module.exports = { '/**/*-(spec|test).js?(x)', ], transform: { - '^.+\\.(js|jsx)$': require.resolve('./transform/jsTransform.js'), + '^.+\\.(mjs|cjs|js|jsx)$': require.resolve('./transform/jsTransform.js'), '^.+\\.s?css$': require.resolve('./transform/cssTransform.js'), '^(?!.*\\.(js|jsx|css|json)$)': require.resolve( './transform/fileTransform.js' diff --git a/packages/upgrade/.gitignore b/packages/upgrade/.gitignore new file mode 100644 index 000000000000..7debd63c66c7 --- /dev/null +++ b/packages/upgrade/.gitignore @@ -0,0 +1,2 @@ +# Generated by `esbuild` using `yarn build` +/cli.js diff --git a/packages/upgrade/README.md b/packages/upgrade/README.md index 4482aebf4aed..cc1d1cfb48b7 100644 --- a/packages/upgrade/README.md +++ b/packages/upgrade/README.md @@ -1,6 +1,6 @@ # @carbon/upgrade -> A tool for upgrading Carbon versions +> A tool for upgrading projects that use Carbon ## Getting started @@ -24,28 +24,24 @@ You can install `@carbon/upgrade` in your project, or use a tool like [`npx`](https://medium.com/@maybekatz/introducing-npx-an-npm-package-runner-55f7d4bd282b) by running the following command in your project: - # Runs the command in "dry" mode, which means no files are altered. - # To update the files, re-run the command without the `-d` flag. - npx @carbon/upgrade -d +```bash +npx @carbon/upgrade -d +``` Below is a full output of the options and commands available: ```bash -Usage: carbon-upgrade [options] - -Commands: - carbon-upgrade run to upgrade your project[default] - carbon-upgrade migrate run a specific migration for a - package +Usage: @carbon/upgrade [options] Options: - --help Show help [boolean] - --version Show version number [boolean] - --verbose display the full output while running a command [default: false] - --dry, -d view the result of running this command without changing any - files [default: false] - --ignore, -i provide a glob pattern for directories you would like ignored - [default: ""] + --help Show help [boolean] + --version Show version number [boolean] + --force force execution if the cli encounters an error while doing + safety checks [boolean] [default: false] + -w, --write update the files with changes found by running the migration + [default: false] + -v, --verbose optionally include additional logs, useful for debugging + [boolean] [default: false] ``` ## 🙌 Contributing diff --git a/packages/upgrade/__mocks__/inquirer.js b/packages/upgrade/__mocks__/inquirer.js new file mode 100644 index 000000000000..c4810160fe47 --- /dev/null +++ b/packages/upgrade/__mocks__/inquirer.js @@ -0,0 +1,46 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const mockAnswers = new Map(); + +const prompt = jest.fn().mockImplementation(async (questions) => { + return questions + .map((question) => { + const answer = mockAnswers.get(question.name); + if (!answer) { + throw new Error( + `Invalid mock usage for \`inquirer\`. Expected an answer to be ` + + `mocked before prompt was called for question \`${question.name}\`` + ); + } + mockAnswers.delete(question.name); + return { + key: question.name, + value: answer, + }; + }) + .reduce((acc, { key, value }) => { + return { + ...acc, + [key]: value, + }; + }, {}); +}); + +function mockAnswer(key, value) { + mockAnswers.set(key, value); + return { + mockAnswers, + }; +} + +module.exports = { + prompt, + mockAnswer, +}; diff --git a/packages/upgrade/bin/carbon-upgrade.js b/packages/upgrade/bin/carbon-upgrade.js index aa8ac762f224..7babb00672d0 100755 --- a/packages/upgrade/bin/carbon-upgrade.js +++ b/packages/upgrade/bin/carbon-upgrade.js @@ -18,24 +18,20 @@ process.on('unhandledRejection', (error) => { console.error(error); }); -var chalk = require('chalk'); - var currentNodeVersion = process.versions.node; var semver = currentNodeVersion.split('.'); var major = semver[0]; if (major < 14) { console.error( - chalk.red( - `You are running Node ${currentNodeVersion}.\n` + - `carbon-upgrade requires Node 14 or higher, please update your ` + - `version of Node.` - ) + `You are running Node ${currentNodeVersion}.\n` + + `carbon-upgrade requires Node 14 or higher, please update your ` + + `version of Node.` ); process.exit(1); } -var main = require('../src/cli'); +var { main } = require('../cli'); main(process).catch((error) => { console.error(error); diff --git a/packages/upgrade/fixtures/sample-project/package.json b/packages/upgrade/fixtures/sample-project/package.json new file mode 100644 index 000000000000..5f9025704d51 --- /dev/null +++ b/packages/upgrade/fixtures/sample-project/package.json @@ -0,0 +1,8 @@ +{ + "name": "sample-project", + "dependencies": { + "carbon-components": "^10.49.0", + "carbon-components-react": "^7.49.0", + "carbon-icons": "^7.0.7" + } +} diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json index c255f0d5dcd3..db6b04497911 100644 --- a/packages/upgrade/package.json +++ b/packages/upgrade/package.json @@ -14,7 +14,7 @@ "bugs": "https://github.com/carbon-design-system/carbon/issues", "files": [ "bin", - "src" + "cli.js" ], "keywords": [ "carbon", @@ -29,10 +29,17 @@ "publishConfig": { "access": "public" }, - "dependencies": { + "scripts": { + "build": "esbuild src/cli.js --bundle --platform=node --outfile=cli.js --target=node14", + "clean": "rimraf cli.js", + "watch": "yarn build --watch" + }, + "devDependencies": { "chalk": "^4.1.1", "change-case": "^4.1.2", "cross-spawn": "^7.0.3", + "esbuild": "^0.14.10", + "execa": "^5.1.1", "fast-glob": "^3.2.7", "fs-extra": "^10.0.0", "inquirer": "^8.1.0", @@ -41,11 +48,11 @@ "jscodeshift": "^0.13.0", "lodash.clonedeep": "^4.5.0", "lodash.merge": "^4.6.2", + "memfs": "^3.4.0", + "nanoid": "^3.1.30", "npm-which": "^3.0.1", + "rimraf": "^3.0.2", "semver": "^7.3.5", "yargs": "^17.0.1" - }, - "devDependencies": { - "memfs": "^3.4.0" } } diff --git a/packages/upgrade/src/__mocks__/logger.js b/packages/upgrade/src/__mocks__/logger.js new file mode 100644 index 000000000000..b96a9118969b --- /dev/null +++ b/packages/upgrade/src/__mocks__/logger.js @@ -0,0 +1,19 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +const logger = { + setLevel: jest.fn(), + log: jest.fn(), +}; + +const levels = ['error', 'warn', 'info', 'verbose', 'debug', 'silly']; + +for (const level of levels) { + logger[level] = jest.fn(); +} + +export { logger }; diff --git a/packages/upgrade/src/project/__tests__/workspace-test.js b/packages/upgrade/src/__tests__/workspace-test.js similarity index 92% rename from packages/upgrade/src/project/__tests__/workspace-test.js rename to packages/upgrade/src/__tests__/workspace-test.js index cc6e31799e69..f6f406d097f4 100644 --- a/packages/upgrade/src/project/__tests__/workspace-test.js +++ b/packages/upgrade/src/__tests__/workspace-test.js @@ -44,12 +44,6 @@ describe('Workspace', () => { directory ) ); - const workspace = await Workspace.load(directory); - - expect( - Array.from(workspace.getWorkspaces()).map((w) => w.directory) - ).toEqual(['/test']); - await expect(Workspace.load(directory)).resolves.toBeInstanceOf(Workspace); }); @@ -82,7 +76,6 @@ describe('Workspace', () => { expect( Array.from(workspace.getWorkspaces()).map((w) => w.directory) ).toEqual([ - '/test', '/test/packages/a', '/test/packages/b', '/test/packages/c', diff --git a/packages/upgrade/src/cli.js b/packages/upgrade/src/cli.js index 05e906c9563a..764d1f9181eb 100644 --- a/packages/upgrade/src/cli.js +++ b/packages/upgrade/src/cli.js @@ -5,96 +5,112 @@ * LICENSE file in the root directory of this source tree. */ -'use strict'; +import chalk from 'chalk'; +import isGitClean from 'is-git-clean'; +import { upgrade } from './commands/upgrade'; +import { UpgradeError } from './error'; +import { logger } from './logger'; +import { upgrades } from './upgrades'; +import packageJson from '../package.json'; +// Note: for esbuild we need this import to be CommonJS +// - https://github.com/yargs/yargs/issues/1929 +// - https://github.com/evanw/esbuild/issues/1492 +// - https://github.com/yargs/yargs/blob/main/docs/bundling.md#esbuild const cli = require('yargs'); -const isGitClean = require('is-git-clean'); -const packageJson = require('../package.json'); -const { UpgradeError } = require('./error'); -const { Migration } = require('./migration'); -const { Planner } = require('./planner'); -const { Project } = require('./project'); -const { Runner } = require('./runner'); -async function main({ argv, cwd }) { +export async function main({ argv, cwd }) { cli.scriptName(packageJson.name).version(packageJson.version); cli - .option('verbose', { + .option('force', { default: false, - describe: 'display the full output while running a command', + describe: + 'force execution if the cli encounters an error while doing safety checks', + type: 'boolean', }) .option('write', { alias: 'w', - describe: 'update the files with changes found by running the migration', default: false, + describe: 'update the files with changes found by running the migration', + type: 'boolean', }) - .option('ignore', { - alias: 'i', - describe: - 'provide a list of glob pattern for directories you would like ignored', - default: [], - array: true, + .option('verbose', { + alias: 'v', + default: false, + describe: 'optionally include additional logs, useful for debugging', + type: 'boolean', }); cli.usage('Usage: $0 [options]').command( '$0', - 'run to upgrade your project', + 'upgrade your project', {}, run(async (args) => { - const { ignore, verbose, write } = args; + const { verbose, write } = args; const options = { cwd: cwd(), - ignore, verbose, write, }; - - const project = await Project.detect(options.cwd); - const migrationsByWorkspace = await Migration.getMigrationsByWorkspace( - Array.from(project.getWorkspaces()), - Migration.getMigrations() - ); - const migrationsToRun = await Planner.getSelectedMigrations( - migrationsByWorkspace - ); - - await Runner.run(migrationsToRun, options); + await upgrade(options, upgrades); }) ); - cli.strict().parse(argv.slice(2)).argv; + cli.strict().parse(argv.slice(2)); } +/** + * @param {Function} command + * @returns {Function} + */ function run(command) { - return async (...args) => { - // checks git status on pwd, returns true if clean / false if not - let isClean = isGitClean.sync(); + return async (args) => { + if (args.verbose === true) { + logger.setLevel('verbose'); + } - console.log('Thanks for trying out @carbon/upgrade! 🙏'); - console.log('Checking git status...👀'); + logger.log('Thanks for trying out @carbon/upgrade! 🙏'); - if (!isClean) { - console.error( - 'Git directory is not clean. Please stash or commit your changes.' + // Inspired by react-codemod: + // https://github.com/reactjs/react-codemod/blob/b34b92a1f0b8ad333efe5effb50d17d46d66588b/bin/cli.js#L22 + let clean = false; + + try { + clean = isGitClean.sync(process.cwd()); + } catch (error) { + if ( + error && + error.stderr && + error.stderr.includes('Not a git repository') + ) { + clean = true; + } + } + + if (!clean && args.force !== true) { + logger.log( + chalk.yellow('[warning]'), + 'It appears that you have untracked changes in your project. Before we continue, please stash or commit your changes to git.' + ); + logger.log( + '\nYou may use the --force flag to override this safety check.' ); process.exit(1); } try { - await command(...args); - console.log('Done! ✨'); + await command(args); + logger.log('Done! ✨'); } catch (error) { if (error instanceof UpgradeError) { - console.error(error.message); + logger.error(error.message); process.exit(1); } - console.error('Yikes, looks like something really went wrong.'); - console.error('Please make an issue with the following info:'); - console.log(error); + logger.error('Yikes, looks like something really went wrong.'); + logger.error('Please make an issue with the following info:'); + logger.log(error); process.exit(1); } }; } - -module.exports = main; diff --git a/packages/upgrade/src/commands/__tests__/upgrade-test.js b/packages/upgrade/src/commands/__tests__/upgrade-test.js new file mode 100644 index 000000000000..495552aa901f --- /dev/null +++ b/packages/upgrade/src/commands/__tests__/upgrade-test.js @@ -0,0 +1,501 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import fs from 'fs-extra'; +import { nanoid } from 'nanoid'; +import os from 'os'; +import path from 'path'; + +const directories = new Set(); + +/** + * Helper for creating and clearing temporary directories in a filesystem. + * Useful for simulating projects in test files. + */ +const TempDir = { + /** + * Create a temporary directory + * @returns {Promise} + */ + async create() { + const root = TempDir.getTempDir(); + const id = nanoid(); + const directory = path.join(root, id); + + await fs.ensureDir(directory); + + directories.add(directory); + + return directory; + }, + + /** + * Remove all temporary directories that have been created + * @returns {Promise} + */ + async clear() { + await Promise.all( + Array.from(directories).map((directory) => { + return fs.remove(directory); + }) + ); + directories.clear(); + }, + + /** + * Get the tmp directory as reported by OS + * @returns {string} + */ + getTempDir() { + return os.tmpdir(); + }, +}; + +/** + * Helper class for managing workspaces in a test environment. Workspaces are + * created in temporary directories and will commit files to the filesystem. + * This also abstracts adding child workspaces for monorepo sets to help with + * testing different project setups. + */ +class TempWorkspace { + /** + * @param {object} config + * @param {string} [config.directory] + * @param {object} config.packageJson + * @returns {Promise} + */ + static async create({ directory, packageJson }) { + const root = directory || (await TempDir.create()); + const packageJsonPath = path.join(root, 'package.json'); + await fs.ensureDir(root); + await fs.writeJson(packageJsonPath, packageJson); + return new TempWorkspace(root, packageJson); + } + + constructor(directory, packageJson) { + this.directory = directory; + this.packageJson = packageJson; + this.packageJsonPath = path.join(this.directory, 'package.json'); + } + + /** + * Add a child workspace to the existing workspace with a given packageJson + * @param {object} config + * @param {object} config.packageJson + * @returns {Promise} + */ + async addChildWorkspace({ packageJson }) { + if (!this.workspaces) { + this.workspaces = new Set(); + this.workspaceDirectory = path.join(this.directory, 'packages'); + await fs.ensureDir(this.workspaceDirectory); + await this.set('workspaces', ['packages/*']); + } + + const workspace = await TempWorkspace.create({ + directory: path.join(this.workspaceDirectory, packageJson.name), + packageJson, + }); + + this.workspaces.add(workspace); + + return workspace; + } + + /** + * Get the directory containing this workspace + * @returns {string} + */ + getDirectory() { + return this.directory; + } + + /** + * Set a specific value in the package.json file for a workspace + * @returns {Promise} + */ + async set(key, value) { + this.packageJson = { + ...this.packageJson, + [key]: value, + }; + await fs.writeJson(this.packageJsonPath, this.packageJson); + } + + async getPackageJson() { + return await fs.readJson(this.packageJsonPath); + } +} + +/** + * Provide default and customizable options to pass to commands. These emulate + * what this command is expected to receive from a CLI + */ +const Options = { + default: { + cwd: TempDir.getTempDir(), + verbose: false, + write: false, + }, + + /** + * Returns an options object with the given cwd set + * @param {string} cwd + * @returns {object} + */ + with(cwd, options = {}) { + return { + ...Options.default, + ...options, + cwd, + }; + }, +}; + +describe('commands/upgrade', () => { + let inquirer; + let logger; + let upgrade; + + beforeEach(() => { + jest.mock('inquirer'); + jest.mock('../../logger'); + inquirer = require('inquirer'); + logger = require('../../logger').logger; + upgrade = require('../upgrade').upgrade; + }); + + afterEach(async () => { + await TempDir.clear(); + }); + + test('no workspace', async () => { + await expect( + upgrade(Options.default) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to find a workspace to upgrade"` + ); + }); + + test('single workspace with no upgrades available', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'test', + }, + }); + await upgrade(Options.with(workspace.getDirectory())); + expect(logger.info).toHaveBeenCalledWith('No upgrades available'); + }); + + test('single workspace with upgrade available', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'test', + dependencies: { + 'test-dependency': '1.0.0', + }, + }, + }); + const options = Options.with(workspace.getDirectory(), { + write: true, + }); + + inquirer.mockAnswer('confirm', true); + + await upgrade(options, [ + { + name: 'test-upgrade', + updates: [ + { + package: { + name: 'test-dependency', + range: '1.x', + }, + changes: [ + { + type: 'uninstall', + }, + ], + }, + ], + }, + ]); + + await expect(workspace.getPackageJson()).resolves.toEqual({ + name: 'test', + dependencies: {}, + }); + }); + + test('single workspace with upgrades available', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'test', + dependencies: { + 'test-dependency-a': '1.0.0', + 'test-dependency-b': '2.0.0', + }, + }, + }); + const options = Options.with(workspace.getDirectory(), { + write: true, + }); + + inquirer.mockAnswer('upgrade', 'two-dependencies'); + + await upgrade(options, [ + { + name: 'one-dependency', + updates: [ + { + package: { + name: 'test-dependency-a', + range: '1.x', + }, + changes: [ + { + type: 'uninstall', + }, + ], + }, + ], + }, + { + name: 'two-dependencies', + updates: [ + { + package: { + name: 'test-dependency-a', + range: '1.x', + }, + changes: [ + { + type: 'uninstall', + }, + ], + }, + { + package: { + name: 'test-dependency-b', + range: '2.x', + }, + changes: [ + { + type: 'uninstall', + }, + ], + }, + ], + }, + ]); + + await expect(workspace.getPackageJson()).resolves.toEqual({ + name: 'test', + dependencies: {}, + }); + }); + + test('multiple workspaces, select one with no upgrades available', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'root', + }, + }); + const child = await workspace.addChildWorkspace({ + packageJson: { + name: 'a', + version: '0.0.0', + }, + }); + + inquirer.mockAnswer('workspace', child.getDirectory()); + await upgrade(Options.with(child.getDirectory())); + expect(logger.info).toHaveBeenCalledWith('No upgrades available'); + }); + + test('multiple workspaces, select one with upgrades available', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'root', + }, + }); + const child = await workspace.addChildWorkspace({ + packageJson: { + name: 'a', + version: '0.0.0', + dependencies: { + 'test-upgrade': '0.1.0', + }, + }, + }); + const options = Options.with(child.getDirectory(), { + write: true, + }); + + inquirer.mockAnswer('workspace', child.getDirectory()); + inquirer.mockAnswer('confirm', true); + await upgrade(options, [ + { + name: 'test-upgrade', + updates: [ + { + package: { + name: 'test-upgrade', + range: '0.1.0', + }, + changes: [ + { + type: 'update', + package: { + version: '0.2.0', + }, + }, + ], + }, + ], + }, + ]); + await expect(child.getPackageJson()).resolves.toEqual({ + name: 'a', + version: '0.0.0', + dependencies: { + 'test-upgrade': '0.2.0', + }, + }); + }); + + describe('changes', () => { + test('install', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'test', + dependencies: { + 'test-dependency': '1.0.0', + }, + }, + }); + const options = Options.with(workspace.getDirectory(), { + write: true, + }); + + inquirer.mockAnswer('confirm', true); + + await upgrade(options, [ + { + name: 'test-upgrade', + updates: [ + { + package: { + name: 'test-dependency', + range: '1.x', + }, + changes: [ + { + type: 'install', + package: { + name: 'new-dependency', + version: '1.0.0', + }, + }, + ], + }, + ], + }, + ]); + await expect(workspace.getPackageJson()).resolves.toEqual({ + name: 'test', + dependencies: { + 'test-dependency': '1.0.0', + 'new-dependency': '1.0.0', + }, + }); + }); + + test('uninstall', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'test', + dependencies: { + 'test-dependency': '1.0.0', + }, + }, + }); + const options = Options.with(workspace.getDirectory(), { + write: true, + }); + + inquirer.mockAnswer('confirm', true); + + await upgrade(options, [ + { + name: 'test-upgrade', + updates: [ + { + package: { + name: 'test-dependency', + range: '1.x', + }, + changes: [ + { + type: 'uninstall', + }, + ], + }, + ], + }, + ]); + await expect(workspace.getPackageJson()).resolves.toEqual({ + name: 'test', + dependencies: {}, + }); + }); + + test('update', async () => { + const workspace = await TempWorkspace.create({ + packageJson: { + name: 'test', + dependencies: { + 'test-dependency': '1.0.0', + }, + }, + }); + const options = Options.with(workspace.getDirectory(), { + write: true, + }); + + inquirer.mockAnswer('confirm', true); + + await upgrade(options, [ + { + name: 'test-upgrade', + updates: [ + { + package: { + name: 'test-dependency', + range: '1.x', + }, + changes: [ + { + type: 'update', + package: { + version: '2.0.0', + }, + }, + ], + }, + ], + }, + ]); + await expect(workspace.getPackageJson()).resolves.toEqual({ + name: 'test', + dependencies: { + 'test-dependency': '2.0.0', + }, + }); + }); + }); +}); diff --git a/packages/upgrade/src/commands/upgrade.js b/packages/upgrade/src/commands/upgrade.js new file mode 100644 index 000000000000..0c9030dc1299 --- /dev/null +++ b/packages/upgrade/src/commands/upgrade.js @@ -0,0 +1,164 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import inquirer from 'inquirer'; +import semver from 'semver'; +import { UpgradeError } from '../error'; +import { logger } from '../logger'; +import { Workspace, getAvailableWorkspaces } from '../workspace'; + +export async function upgrade(options, availableUpgrades = []) { + logger.verbose('running upgrade command with options: %o', options); + + const { cwd, write } = options; + const workspaces = getAvailableWorkspaces(cwd); + + if (workspaces.length === 0) { + throw new UpgradeError('Unable to find a workspace to upgrade'); + } + + const workspace = await getSelectedWorkspace(workspaces); + const upgrades = availableUpgrades.filter((upgrade) => { + logger.verbose('checking upgrade: %s', upgrade.name); + + return upgrade.updates.every((update) => { + const dependency = workspace.dependencies.find((dependency) => { + return dependency.name === update.package.name; + }); + + if (dependency) { + return semver.intersects(update.package.range, dependency.version); + } + + return false; + }); + }); + + if (upgrades.length === 0) { + logger.info('No upgrades available'); + return; + } + + const upgrade = await getSelectedUpgrade(upgrades); + if (!upgrade) { + logger.info('No upgrade selected'); + return; + } + + logger.verbose( + 'running upgrade: %s for workspace: %s', + upgrade.name, + workspace.directory + ); + + const packageJson = await workspace.getPackageJson(); + + for (const update of upgrade.updates) { + logger.verbose('applying updates for package: %s', update.package.name); + + for (const change of update.changes) { + logger.verbose('applying change: %o', change); + + if (change.type === 'install') { + packageJson.install(change.package); + } + + if (change.type === 'update') { + packageJson.update({ + name: update.package.name, + version: change.package.version, + }); + } + + if (change.type === 'uninstall') { + packageJson.uninstall(update.package); + } + } + } + + if (packageJson.changed) { + const packageJsonPath = workspace.getPackageJsonPath(); + if (write) { + logger.info('updating file: %s', packageJsonPath); + await workspace.writePackageJson(); + } else { + logger.info('previewing changes for file: %s', packageJsonPath); + logger.log(packageJson.diff()); + } + } +} + +/** + * @param {Array} workspaces + * @returns {Promise} + */ +async function getSelectedWorkspace(workspaces) { + // If only one workspace is available, we'll load that one by default + if (workspaces.length === 1) { + return await Workspace.load(workspaces[0]); + } + + // If multiple workspaces are found, we'll prompt to see which one the user + // would like us to operate off of. + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'workspace', + message: 'What workspace would you like to use?', + choices: workspaces, + }, + ]); + + return await Workspace.load(answers.workspace); +} + +/** + * @param {Array} upgrades + * @returns {Promise} + */ +async function getSelectedUpgrade(upgrades) { + if (upgrades.length === 1) { + const [upgrade] = upgrades; + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: `Would you like to run the ${upgrade.name} upgrade? + (${upgrade.description})`, + }, + ]); + + if (answers.confirm === false) { + return null; + } + + return upgrade; + } + + const answers = await inquirer.prompt([ + { + type: 'list', + name: 'upgrade', + message: 'Which upgrade would you like to run?', + choices: upgrades.map((upgrade) => { + return { + name: `${upgrade.name} (${upgrade.description})`, + value: upgrade.name, + }; + }), + default: 0, + }, + ]); + + if (answers.upgrade === undefined || answers.upgrade === null) { + return null; + } + + return upgrades.find((upgrade) => { + return upgrade.name === answers.upgrade; + }); +} diff --git a/packages/upgrade/src/diff.js b/packages/upgrade/src/diff.js new file mode 100644 index 000000000000..d820349647a1 --- /dev/null +++ b/packages/upgrade/src/diff.js @@ -0,0 +1,28 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import chalk from 'chalk'; +import { diff as jestDiff } from 'jest-diff'; + +/** + * @param {any} a + * @param {any} b + * @param {object} [options] + * @returns {string} + */ +export function diff(a, b, options = {}) { + const defaultOptions = { + aAnnotation: 'Original', + aColor: chalk.red, + bAnnotation: 'Modified', + bColor: chalk.green, + }; + return jestDiff(a, b, { + ...defaultOptions, + ...options, + }); +} diff --git a/packages/upgrade/src/error.js b/packages/upgrade/src/error.js index 21be9df86dd5..cc468dd7df2c 100644 --- a/packages/upgrade/src/error.js +++ b/packages/upgrade/src/error.js @@ -5,8 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -'use strict'; - class CustomError extends Error { constructor(message) { super(message); @@ -14,8 +12,4 @@ class CustomError extends Error { } } -class UpgradeError extends CustomError {} - -module.exports = { - UpgradeError, -}; +export class UpgradeError extends CustomError {} diff --git a/packages/upgrade/src/hash.js b/packages/upgrade/src/hash.js index f22b3c99fcab..d9160ddef6d3 100644 --- a/packages/upgrade/src/hash.js +++ b/packages/upgrade/src/hash.js @@ -5,8 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -'use strict'; - const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; /** @@ -15,7 +13,7 @@ const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; * @param {string} str * @returns {string} */ -function hash(str) { +export function hash(str) { const length = str.length; const rem = length & 3; const len = length ^ rem; @@ -81,7 +79,3 @@ function hash(str) { } return s; } - -module.exports = { - hash, -}; diff --git a/packages/upgrade/src/logger.js b/packages/upgrade/src/logger.js new file mode 100644 index 000000000000..46a35d0d998e --- /dev/null +++ b/packages/upgrade/src/logger.js @@ -0,0 +1,86 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import chalk from 'chalk'; + +const levels = { + error: 0, + warn: 1, + info: 2, + verbose: 3, + debug: 4, + silly: 5, +}; + +/** + * @param {string} level + * @returns {string} + */ +function getColor(level) { + switch (level) { + case 'error': + return chalk.red('[error]'); + case 'warn': + return chalk.yellow('[warn]'); + case 'info': + case 'verbose': + return chalk.blue(`[${level}]`); + case 'debug': + case 'silly': + return chalk.cyan(`[${level}]`); + default: + throw new Error(`Unknown level: ${level}`); + } +} + +const defaultLevel = process.env.NODE_ENV === 'test' ? 'warn' : 'info'; + +/** + * @typedef LoggerInstance + * @property {Function} error + * @property {Function} warn + * @property {Function} info + * @property {Function} verbose + * @property {Function} debug + * @property {Function} silly + * @property {Function} log + */ + +const Logger = { + /** + * @param {string} level + * @returns {LoggerInstance} + */ + create(level = defaultLevel) { + let max = levels[level]; + const logger = { + setLevel(level) { + max = levels[level]; + }, + log(...args) { + console.log(...args); + }, + }; + + function log(level, message, ...args) { + if (levels[level] <= max) { + const formatted = getColor(level); + console.log(`${formatted} ${message}`, ...args); + } + } + + for (const level of Object.keys(levels)) { + logger[level] = (...args) => { + log(level, ...args); + }; + } + + return logger; + }, +}; + +export const logger = Logger.create(process.env.LOG_LEVEL); diff --git a/packages/upgrade/src/migration.js b/packages/upgrade/src/migration.js deleted file mode 100644 index 07cd859ddf59..000000000000 --- a/packages/upgrade/src/migration.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const semver = require('semver'); -const migrations = require('./migrations'); - -const codes = { - // The migration is supported for the current workspace for the matching - // dependency - SUPPORTED: 'supported', - // The migration is supported for the current dependency in the workspace but - // the version range does not intersect - RANGE_MISMATCH: 'range_mismatch', -}; - -/** - * Get the currently supported migrations - * @returns {Array} - */ -function getMigrations() { - return migrations; -} - -function getMigrationsByWorkspace(workspaces, migrations) { - return workspaces - .map((workspace) => { - const { dependencies } = workspace; - - return { - workspace, - migrationOptions: migrations - .filter((migration) => { - return dependencies.find((dependency) => { - return dependency.name === migration.packageName; - }); - }) - .map((migration) => { - const dependency = dependencies.find((dependency) => { - return dependency.name === migration.packageName; - }); - - if (semver.intersects(migration.from, dependency.version)) { - return { - dependency, - migration, - available: true, - code: codes.SUPPORTED, - }; - } - - return { - dependency, - migration, - available: false, - code: codes.RANGE_MISMATCH, - }; - }), - }; - }) - .filter((workspace) => { - return workspace.migrationOptions.length > 0; - }); -} - -function applyMigrations() {} - -/** - * A module for finding and running migrations - */ -const Migration = { - codes, - getMigrations, - getMigrationsByWorkspace, - applyMigrations, -}; - -module.exports = { - Migration, -}; diff --git a/packages/upgrade/src/migrations/index.js b/packages/upgrade/src/migrations/index.js deleted file mode 100644 index 5306cc7c06b3..000000000000 --- a/packages/upgrade/src/migrations/index.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const supported = [ - { - packageName: 'react', - // the range we would like to migrate *from* - from: '>=16', - // the version we would like to migrate *to* - to: '18.0.0', - - async migrate(workspace) { - console.log( - 'migrate the react package in the %s workspace', - workspace.name - ); - }, - }, - { - packageName: 'react-dom', - // the range we would like to migrate *from* - from: '>=16', - // the version we would like to migrate *to* - to: '18.0.0', - - async migrate(workspace) { - console.log( - 'migrate the react-dom package in the %s workspace', - workspace.name - ); - }, - }, -]; - -module.exports = supported; diff --git a/packages/upgrade/src/planner.js b/packages/upgrade/src/planner.js deleted file mode 100644 index ae48e3ca1fab..000000000000 --- a/packages/upgrade/src/planner.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const { prompt } = require('inquirer'); - -const Planner = { - /** - * Prompt the user to see which migrations they would like to apply for each - * workspace - * @param {Array} migrationsByWorkspace - * @returns {Array} - */ - async getSelectedMigrations(migrationsByWorkspace) { - const answers = []; - - for (const { workspace, migrationOptions } of migrationsByWorkspace) { - const answer = await prompt({ - type: 'checkbox', - message: `Migrations available for ${workspace.name}`, - name: 'selected', - choices: migrationOptions - .filter((migrationOption) => { - return migrationOption.available === true; - }) - .map((migrationOption) => { - const { dependency, migration } = migrationOption; - return { - name: `Migrate ${dependency.name} from: ${dependency.version} to: ${migration.to}`, - value: migration, - checked: true, - }; - }), - }); - - answers.push(answer); - } - - return migrationsByWorkspace.map(({ workspace, migrationOptions }, i) => { - const answer = answers[i]; - return { - workspace, - migrationOptions: migrationOptions.filter((migrationOption) => { - return answer.selected.includes(migrationOption.migration); - }), - }; - }); - }, -}; - -module.exports = { - Planner, -}; diff --git a/packages/upgrade/src/project/__tests__/project-test.js b/packages/upgrade/src/project/__tests__/project-test.js deleted file mode 100644 index 7d3d0f47308e..000000000000 --- a/packages/upgrade/src/project/__tests__/project-test.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - * - * @jest-environment node - */ - -'use strict'; - -const { Volume } = require('memfs'); - -function setupProject(mockVol) { - jest.mock('fs', () => { - const { createFsFromVolume } = require('memfs'); - const fs = createFsFromVolume(mockVol); - - // Our usage of `fs-extra` requires a fs.realpath.native value that memfs - // does not seem to provide. - Object.defineProperty(fs.realpath, 'native', { - value: jest.fn(), - enumerable: true, - writable: false, - }); - - return fs; - }); - return require('../project').Project; -} - -describe('Project', () => { - afterEach(() => { - jest.resetModules(); - }); - - it('should detect a project from the current working directory', async () => { - const directory = '/test'; - const Project = setupProject( - Volume.fromJSON( - { - './package.json': JSON.stringify({}), - './src/test.js': '', - }, - directory - ) - ); - - const project = await Project.detect('/test/src'); - expect(project.directory).toEqual('/test'); - }); -}); diff --git a/packages/upgrade/src/project/index.js b/packages/upgrade/src/project/index.js deleted file mode 100644 index d64c8d3e6002..000000000000 --- a/packages/upgrade/src/project/index.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const { Project } = require('./project'); -const { Workspace } = require('./workspace'); - -module.exports = { - Project, - Workspace, -}; diff --git a/packages/upgrade/src/project/project.js b/packages/upgrade/src/project/project.js deleted file mode 100644 index a75e779fafd1..000000000000 --- a/packages/upgrade/src/project/project.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const fs = require('fs-extra'); -const path = require('path'); -const { UpgradeError } = require('../error'); -const { Workspace } = require('./workspace'); - -class Project { - /** - * Detects the project root directory from a given current working directory - * @param {string} directory - * @returns Promise - */ - static async detect(directory) { - const rootDirectory = getProjectRoot(directory); - const workspace = await Workspace.load(rootDirectory); - return Project.create(rootDirectory, workspace); - } - - static create(directory, workspace) { - return new Project(directory, workspace); - } - - constructor(directory, workspace) { - this.directory = directory; - this.workspace = workspace; - } - - getWorkspaces() { - return Array.from(this.workspace.getWorkspaces()); - } -} - -/** - * Returns the root directory of a project, either as a workspace root with a - * collection of packages or a single project with a `package.json` - * @param {string} directory - * @returns {string} - */ -function getProjectRoot(directory) { - const packageJsonPaths = ancestors(directory).filter((directory) => { - return fs.existsSync(path.join(directory, 'package.json')); - }); - - const rootDirectory = - packageJsonPaths.length > 0 - ? packageJsonPaths[packageJsonPaths.length - 1] - : null; - - if (!rootDirectory) { - throw new UpgradeError( - `Unable to find a \`package.json\` file from directory: ${directory}` - ); - } - - return rootDirectory; -} - -const { root: ROOT_DIR } = path.parse(__dirname); - -/** - * Returns an array of the the directory and its ancestors - * @param {string} directory - * @returns {Array} - */ -function ancestors(directory) { - const result = [directory]; - let current = directory; - - while (current !== '') { - result.push(current); - - if (current !== ROOT_DIR) { - current = path.dirname(current); - } else { - current = ''; - } - } - - return result; -} - -module.exports = { - Project, -}; diff --git a/packages/upgrade/src/project/workspace.js b/packages/upgrade/src/project/workspace.js deleted file mode 100644 index 2df6ba60af17..000000000000 --- a/packages/upgrade/src/project/workspace.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const glob = require('fast-glob'); -const fs = require('fs-extra'); -const merge = require('lodash.merge'); -const path = require('path'); -const semver = require('semver'); -const { UpgradeError } = require('../error'); -const { hash } = require('../hash'); - -class Workspace { - /** - * @param {string} directory - * @returns {object} - */ - static async load(directory) { - const tree = await loadWorkspace(directory); - const visited = new Map(); - const workspace = visit(tree); - - function visit(node) { - let workspace = Workspace.create(node); - - if (visited.has(workspace.id)) { - return visited.get(workspace.id); - } - - visited.set(workspace.id, workspace); - - if (node.type === 'worktree') { - node.children.forEach((node) => { - const child = visit(node); - workspace.addChildWorkspace(child); - }); - } - - return workspace; - } - - for (const node of visited.values()) { - for (const dependency of node.dependencies.values()) { - if (!visited.has(dependency.name)) { - continue; - } - const workspace = visited.get(dependency.name); - if (semver.satisfies(workspace.version, dependency.version)) { - dependency.workspace = workspace; - } - } - } - - return workspace; - } - - static create(workspace) { - return new Workspace(workspace); - } - - constructor({ directory, name, version, dependencies }) { - this.directory = directory; - this.name = name; - this.version = version; - this.dependencies = dependencies; - this.workspaces = new Set(); - this.id = hash(this.directory); - } - - addChildWorkspace(workspace) { - this.workspaces.add(workspace); - } - - *getWorkspaces() { - yield this; - for (const workspace of this.workspaces) { - yield* workspace.getWorkspaces(); - } - } - - getPackageJson() { - const packageJsonPath = path.join(this.directory, 'package.json'); - return fs.readJson(packageJsonPath); - } - - async updatePackageJson(packageJson) { - const packageJsonPath = path.join(this.directory, 'package.json'); - const current = await this.getPackageJson(); - await fs.writeJson(packageJsonPath, merge(current, packageJson), { - spaces: 2, - }); - } -} - -async function loadWorkspace(directory) { - const packageJsonPath = path.join(directory, 'package.json'); - - if (!fs.existsSync(packageJsonPath)) { - throw new UpgradeError(`Unable to find package.json at ${packageJsonPath}`); - } - - const packageJson = await fs.readJson(packageJsonPath); - const types = ['dependencies', 'devDependencies', 'peerDependencies']; - const dependencies = []; - - for (const type of types) { - if (!packageJson[type]) { - continue; - } - - for (const [name, version] of Object.entries(packageJson[type])) { - dependencies.push({ - type, - name, - version, - }); - } - } - - if (packageJson.workspaces) { - if ( - !Array.isArray(packageJson.workspaces) && - !Array.isArray(packageJson.workspaces.packages) - ) { - throw new UpgradeError( - `Invalid workspace configuration found at ${packageJsonPath}` - ); - } - - const patterns = Array.isArray(packageJson.workspaces) - ? packageJson.workspaces - : packageJson.workspaces.packages; - const children = await glob( - patterns.map((pattern) => `${pattern}/package.json`), - { - cwd: directory, - } - ).then((matches) => { - return Promise.all( - matches.map((match) => { - return loadWorkspace(path.dirname(path.join(directory, match))); - }) - ); - }); - - return { - type: 'worktree', - directory, - dependencies, - children, - name: packageJson.name, - version: packageJson.version, - }; - } - - return { - type: 'workspace', - directory, - dependencies, - name: packageJson.name, - version: packageJson.version, - }; -} - -module.exports = { - Workspace, -}; diff --git a/packages/upgrade/src/runner.js b/packages/upgrade/src/runner.js deleted file mode 100644 index dfb26ca3d207..000000000000 --- a/packages/upgrade/src/runner.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright IBM Corp. 2019, 2019 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -'use strict'; - -const chalk = require('chalk'); -const clone = require('lodash.cloneDeep'); -const { diff } = require('jest-diff'); - -const logger = { - info(message) { - console.log(`${chalk.yellow.inverse(' INFO ')} ${message}`); - }, -}; - -const Runner = { - /** - * Run the given migrations for each workspace - * @param {Array} updates + */ + +/** + * @typedef Update + * @property {object} package + * @property {string} package.name + * @property {string} package.version + * @property {Array} changes + */ + +/** + * Represents all possible changes made in a project's package.json file for a + * package + */ +const Change = { + /** + * @param {object} options + * @param {string} options.name + * @param {string} options.version + * @returns {object} + */ + install({ name, version }) { + return { + type: 'install', + package: { + name, + version, + }, + }; + }, + + uninstall: { + type: 'uninstall', + }, + + /** + * @param {object} options + * @param {string} options.version + * @returns {object} + */ + update({ version }) { + return { + type: 'update', + package: { + version, + }, + }; + }, +}; + +/** + * @type {Array} + */ +export const upgrades = [ + { + name: 'v11: full update', + description: + 'changes carbon-components, carbon-components-react, carbon-icons, and @carbon/icons-react to @carbon/react', + updates: [ + { + package: { + name: 'carbon-components', + range: '10.x', + }, + changes: [Change.uninstall], + }, + { + package: { + name: 'carbon-icons', + range: '7.x', + }, + changes: [Change.uninstall], + }, + { + package: { + name: 'carbon-components-react', + range: '7.x', + }, + changes: [ + Change.uninstall, + Change.install({ + name: '@carbon/react', + version: '~1.0.0', + }), + ], + }, + { + package: { + name: '@carbon/icons-react', + range: '10.x', + }, + changes: [Change.uninstall], + }, + ], + }, + { + name: 'v11: default update', + description: + 'changes carbon-components, carbon-components-react, and carbon-icons to @carbon/react', + updates: [ + { + package: { + name: 'carbon-components', + range: '10.x', + }, + changes: [Change.uninstall], + }, + { + package: { + name: 'carbon-components-react', + range: '7.x', + }, + changes: [ + Change.uninstall, + Change.install({ + name: '@carbon/react', + version: '~1.0.0', + }), + ], + }, + { + package: { + name: 'carbon-icons', + range: '7.x', + }, + changes: [Change.uninstall], + }, + ], + }, + { + name: 'v11: carbon-components', + description: 'changes carbon-components to @carbon/styles', + updates: [ + { + package: { + name: 'carbon-components', + range: '10.x', + }, + changes: [ + Change.uninstall, + Change.install({ + name: '@carbon/styles', + version: '~1.0.0', + }), + ], + }, + ], + }, +]; diff --git a/packages/upgrade/src/workspace.js b/packages/upgrade/src/workspace.js new file mode 100644 index 000000000000..475eb0436fe4 --- /dev/null +++ b/packages/upgrade/src/workspace.js @@ -0,0 +1,296 @@ +/** + * Copyright IBM Corp. 2019, 2019 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import fs from 'fs-extra'; +import glob from 'fast-glob'; +import clone from 'lodash.clonedeep'; +import path from 'path'; +import semver from 'semver'; +import { diff } from './diff'; +import { UpgradeError } from './error'; +import { hash } from './hash'; + +class Workspace { + /** + * @param {string} directory + * @returns {Promise} + */ + static async load(directory) { + const tree = await loadWorkspace(directory); + const visited = new Map(); + const workspace = visit(tree); + + function visit(node) { + let workspace = Workspace.create(node); + + if (visited.has(workspace.id)) { + return visited.get(workspace.id); + } + + visited.set(workspace.id, workspace); + + if (node.type === 'worktree') { + node.children.forEach((node) => { + const child = visit(node); + workspace.addChildWorkspace(child); + }); + } + + return workspace; + } + + for (const node of visited.values()) { + for (const dependency of node.dependencies.values()) { + if (!visited.has(dependency.name)) { + continue; + } + const workspace = visited.get(dependency.name); + if (semver.satisfies(workspace.version, dependency.version)) { + dependency.workspace = workspace; + } + } + } + + return workspace; + } + + static create(workspace) { + return new Workspace(workspace); + } + + constructor({ directory, name, version, dependencies }) { + this.directory = directory; + this.name = name; + this.version = version; + this.dependencies = dependencies; + this.workspaces = new Set(); + this.id = hash(this.directory); + } + + addChildWorkspace(workspace) { + this.workspaces.add(workspace); + } + + getWorkspaces() { + return Array.from(this.workspaces).flatMap((workspace) => { + return [workspace, ...workspace.getWorkspaces()]; + }); + } + + async getPackageJson() { + if (!this.packageJson) { + const packageJson = await fs.readJson(this.getPackageJsonPath()); + this.packageJson = PackageJson.create(packageJson); + } + return this.packageJson; + } + + async writePackageJson() { + if (this.packageJson.changed) { + const packageJson = this.packageJson.getJSON(); + await fs.writeJson(this.getPackageJsonPath(), packageJson, { + spaces: 2, + }); + this.packageJson = PackageJson.create(packageJson); + } + } + + getPackageJsonPath() { + return path.join(this.directory, 'package.json'); + } +} + +async function loadWorkspace(directory) { + const packageJsonPath = path.join(directory, 'package.json'); + + if (!fs.existsSync(packageJsonPath)) { + throw new UpgradeError(`Unable to find package.json at ${packageJsonPath}`); + } + + const packageJson = await fs.readJson(packageJsonPath); + const types = ['dependencies', 'devDependencies', 'peerDependencies']; + const dependencies = []; + + for (const type of types) { + if (!packageJson[type]) { + continue; + } + + for (const [name, version] of Object.entries(packageJson[type])) { + dependencies.push({ + type, + name, + version, + }); + } + } + + if (packageJson.workspaces) { + if ( + !Array.isArray(packageJson.workspaces) && + !Array.isArray(packageJson.workspaces.packages) + ) { + throw new UpgradeError( + `Invalid workspace configuration found at ${packageJsonPath}` + ); + } + + const patterns = Array.isArray(packageJson.workspaces) + ? packageJson.workspaces + : packageJson.workspaces.packages; + const children = await glob( + patterns.map((pattern) => `${pattern}/package.json`), + { + cwd: directory, + } + ).then((matches) => { + return Promise.all( + matches.map((match) => { + return loadWorkspace(path.dirname(path.join(directory, match))); + }) + ); + }); + + return { + type: 'worktree', + directory, + dependencies, + children, + name: packageJson.name, + version: packageJson.version, + }; + } + + return { + type: 'workspace', + directory, + dependencies, + name: packageJson.name, + version: packageJson.version, + }; +} + +const { root: ROOT_DIR } = path.parse(__dirname); + +/** + * Returns an array of the the directory and its ancestors + * @param {string} directory + * @returns {Array} + */ +function ancestors(directory) { + const result = []; + let current = directory; + + while (current !== '') { + result.push(current); + + if (current !== ROOT_DIR) { + current = path.dirname(current); + } else { + current = ''; + } + } + + return result; +} + +/** + * @param {string} directory + * @returns {Array} + */ +function getAvailableWorkspaces(directory) { + return ancestors(directory).filter((directory) => { + return fs.existsSync(path.join(directory, 'package.json')); + }); +} + +class PackageJson { + /** + * @param {object} packageJson + * @returns {PackageJson} + */ + static create(packageJson) { + return new PackageJson(packageJson); + } + + static dependencyTypes = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ]; + + constructor(packageJson) { + this.original = packageJson; + this.modified = clone(this.original); + this.changed = false; + } + + install({ name, version, type = 'dependencies' }) { + const exists = PackageJson.dependencyTypes.find((type) => { + if (this.modified[type]) { + return this.modified[type][name]; + } + return false; + }); + + if (exists) { + throw new UpgradeError( + `The dependency \`${name}\` alreadys exists and cannot be added` + ); + } + + this.changed = true; + if (!this.modified[type]) { + this.modified[type] = {}; + } + this.modified[type][name] = version; + } + + update({ name, version }) { + const type = PackageJson.dependencyTypes.find((type) => { + return this.modified[type][name]; + }); + + if (type) { + this.changed = true; + this.modified[type][name] = version; + } else { + throw new Error(`Unable to find dependency type for: \`${name}\``); + } + } + + uninstall({ name }) { + const types = PackageJson.dependencyTypes.filter((type) => { + if (this.original[type]) { + return this.original[type][name]; + } + return false; + }); + + if (types.length === 0) { + throw new Error(`Unable to find and remove dependency: \`${name}\``); + } + + this.changed = true; + for (const type of types) { + delete this.modified[type][name]; + } + } + + getJSON() { + return this.modified; + } + + diff() { + return diff( + JSON.stringify(this.original, null, 2), + JSON.stringify(this.modified, null, 2) + ); + } +} + +export { Workspace, getAvailableWorkspaces }; diff --git a/yarn.lock b/yarn.lock index be530ed61bd9..c0bbc71d9c92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2250,6 +2250,8 @@ __metadata: chalk: ^4.1.1 change-case: ^4.1.2 cross-spawn: ^7.0.3 + esbuild: ^0.14.10 + execa: ^5.1.1 fast-glob: ^3.2.7 fs-extra: ^10.0.0 inquirer: ^8.1.0 @@ -2259,7 +2261,9 @@ __metadata: lodash.clonedeep: ^4.5.0 lodash.merge: ^4.6.2 memfs: ^3.4.0 + nanoid: ^3.1.30 npm-which: ^3.0.1 + rimraf: ^3.0.2 semver: ^7.3.5 yargs: ^17.0.1 bin: @@ -16075,6 +16079,197 @@ __metadata: languageName: node linkType: hard +"esbuild-android-arm64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-android-arm64@npm:0.14.10" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"esbuild-darwin-64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-darwin-64@npm:0.14.10" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"esbuild-darwin-arm64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-darwin-arm64@npm:0.14.10" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"esbuild-freebsd-64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-freebsd-64@npm:0.14.10" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"esbuild-freebsd-arm64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-freebsd-arm64@npm:0.14.10" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"esbuild-linux-32@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-linux-32@npm:0.14.10" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"esbuild-linux-64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-linux-64@npm:0.14.10" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"esbuild-linux-arm64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-linux-arm64@npm:0.14.10" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"esbuild-linux-arm@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-linux-arm@npm:0.14.10" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"esbuild-linux-mips64le@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-linux-mips64le@npm:0.14.10" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"esbuild-linux-ppc64le@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-linux-ppc64le@npm:0.14.10" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"esbuild-linux-s390x@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-linux-s390x@npm:0.14.10" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"esbuild-netbsd-64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-netbsd-64@npm:0.14.10" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"esbuild-openbsd-64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-openbsd-64@npm:0.14.10" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"esbuild-sunos-64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-sunos-64@npm:0.14.10" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"esbuild-windows-32@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-windows-32@npm:0.14.10" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"esbuild-windows-64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-windows-64@npm:0.14.10" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"esbuild-windows-arm64@npm:0.14.10": + version: 0.14.10 + resolution: "esbuild-windows-arm64@npm:0.14.10" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"esbuild@npm:^0.14.10": + version: 0.14.10 + resolution: "esbuild@npm:0.14.10" + dependencies: + esbuild-android-arm64: 0.14.10 + esbuild-darwin-64: 0.14.10 + esbuild-darwin-arm64: 0.14.10 + esbuild-freebsd-64: 0.14.10 + esbuild-freebsd-arm64: 0.14.10 + esbuild-linux-32: 0.14.10 + esbuild-linux-64: 0.14.10 + esbuild-linux-arm: 0.14.10 + esbuild-linux-arm64: 0.14.10 + esbuild-linux-mips64le: 0.14.10 + esbuild-linux-ppc64le: 0.14.10 + esbuild-linux-s390x: 0.14.10 + esbuild-netbsd-64: 0.14.10 + esbuild-openbsd-64: 0.14.10 + esbuild-sunos-64: 0.14.10 + esbuild-windows-32: 0.14.10 + esbuild-windows-64: 0.14.10 + esbuild-windows-arm64: 0.14.10 + dependenciesMeta: + esbuild-android-arm64: + optional: true + esbuild-darwin-64: + optional: true + esbuild-darwin-arm64: + optional: true + esbuild-freebsd-64: + optional: true + esbuild-freebsd-arm64: + optional: true + esbuild-linux-32: + optional: true + esbuild-linux-64: + optional: true + esbuild-linux-arm: + optional: true + esbuild-linux-arm64: + optional: true + esbuild-linux-mips64le: + optional: true + esbuild-linux-ppc64le: + optional: true + esbuild-linux-s390x: + optional: true + esbuild-netbsd-64: + optional: true + esbuild-openbsd-64: + optional: true + esbuild-sunos-64: + optional: true + esbuild-windows-32: + optional: true + esbuild-windows-64: + optional: true + esbuild-windows-arm64: + optional: true + bin: + esbuild: bin/esbuild + checksum: 0e42a74afa79f8f096d338415b6661410fb656ce0d8ea6f48ccbd943d383e01d946dc9124e366db7dc9deb02b924c6788a68d690a1aa28365e891102450e8ae5 + languageName: node + linkType: hard + "escalade@npm:^3.0.2, escalade@npm:^3.1.1": version: 3.1.1 resolution: "escalade@npm:3.1.1" @@ -16926,7 +17121,7 @@ __metadata: languageName: node linkType: hard -"execa@npm:^5.0.0": +"execa@npm:^5.0.0, execa@npm:^5.1.1": version: 5.1.1 resolution: "execa@npm:5.1.1" dependencies: