diff --git a/.eslintrc.js b/.eslintrc.js index 33d3734..778d9c3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,13 +29,8 @@ module.exports = { next: 'multiline-block-like', }, ], - // This prevents using bulleted/numbered lists in JSDoc blocks. - // See: - 'jsdoc/check-indentation': 'off', // It's common for scripts to access `process.env` 'node/no-process-env': 'off', - // It's common for scripts to exit explicitly - 'node/no-process-exit': 'off', }, overrides: [ diff --git a/.nvmrc b/.nvmrc index 958b5a3..6f7f377 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v14 +v16 diff --git a/README.md b/README.md index 2075a8b..15883d4 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ For more on how to use this tool, please see the [docs](./docs). ### Setup -- Install [Node.js](https://nodejs.org) version 12 +- Install [Node.js](https://nodejs.org) version 16 - If you are using [nvm](/~https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. + - Note that the version of Node used for development (in `.nvmrc`) is intentionally higher than version used for consumption (as `engines` in `package.json`), as we have not fully phased out legacy versions of Node from our products yet. - Install [Yarn v3](https://yarnpkg.com/getting-started/install) - Run `yarn install` to install dependencies and run any required post-install scripts diff --git a/jest.config.js b/jest.config.js index 83e1504..be55ba2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -28,9 +28,11 @@ module.exports = { coverageDirectory: 'coverage', // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "/node_modules/" - // ], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/src/cli.ts', + '/src/command-line-arguments.ts', + ], // Indicates which provider should be used to instrument code for coverage coverageProvider: 'babel', @@ -135,7 +137,7 @@ module.exports = { // setupFiles: [], // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], + setupFilesAfterEnv: ['./tests/setupAfterEnv.ts'], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, diff --git a/package.json b/package.json index a3cb95d..75aedf5 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "type": "git", "url": "/~https://github.com/MetaMask/create-release-branch.git" }, - "main": "dist/index.js", - "types": "dist/index.d.ts", + "bin": "dist/cli.js", "files": [ "dist/" ], @@ -18,10 +17,22 @@ "lint:eslint": "eslint . --cache --ext js,ts", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' '!.yarnrc.yml' --ignore-path .gitignore --no-error-on-unmatched-pattern", - "prepack": "yarn build", + "prepack": "yarn build:clean && chmod +x dist/cli.js && yarn lint && yarn test", "test": "jest && jest-it-up", "test:watch": "jest --watch" }, + "dependencies": { + "@metamask/action-utils": "^0.0.2", + "@metamask/utils": "^2.0.0", + "debug": "^4.3.4", + "execa": "^5.0.0", + "glob": "^8.0.3", + "pony-cause": "^2.1.0", + "semver": "^7.3.7", + "which": "^2.0.2", + "yaml": "^2.1.1", + "yargs": "^17.5.1" + }, "devDependencies": { "@lavamoat/allow-scripts": "^2.0.3", "@metamask/auto-changelog": "^2.3.0", @@ -29,8 +40,13 @@ "@metamask/eslint-config-jest": "^9.0.0", "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", + "@types/debug": "^4.1.7", "@types/jest": "^28.1.4", + "@types/jest-when": "^3.5.2", "@types/node": "^17.0.23", + "@types/rimraf": "^3.0.2", + "@types/which": "^2.0.1", + "@types/yargs": "^17.0.10", "@typescript-eslint/eslint-plugin": "^4.21.0", "@typescript-eslint/parser": "^4.21.0", "eslint": "^7.23.0", @@ -42,9 +58,12 @@ "eslint-plugin-prettier": "^3.3.1", "jest": "^28.0.0", "jest-it-up": "^2.0.2", + "jest-when": "^3.5.1", + "nanoid": "^3.3.4", "prettier": "^2.2.1", "prettier-plugin-packagejson": "^2.2.17", "rimraf": "^3.0.2", + "stdio-mock": "^1.2.0", "ts-jest": "^28.0.0", "ts-node": "^10.7.0", "typescript": "^4.2.4" diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..f01666a --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,18 @@ +import { main } from './main'; + +/** + * The entrypoint to this tool. + */ +async function cli() { + await main({ + argv: process.argv, + cwd: process.cwd(), + stdout: process.stdout, + stderr: process.stderr, + }); +} + +cli().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/command-line-arguments.ts b/src/command-line-arguments.ts new file mode 100644 index 0000000..753a162 --- /dev/null +++ b/src/command-line-arguments.ts @@ -0,0 +1,43 @@ +import yargs from 'yargs/yargs'; +import { hideBin } from 'yargs/helpers'; + +export interface CommandLineArguments { + projectDirectory: string; + tempDirectory: string | undefined; + reset: boolean; +} + +/** + * Parses the arguments provided on the command line using `yargs`. + * + * @param argv - The name of this executable and its arguments (as obtained via + * `process.argv`). + * @returns A promise for the `yargs` arguments object. + */ +export async function readCommandLineArguments( + argv: string[], +): Promise { + return await yargs(hideBin(argv)) + .usage( + 'This tool prepares your project for a new release by bumping versions and updating changelogs.', + ) + .option('project-directory', { + alias: 'd', + describe: 'The directory that holds your project.', + default: '.', + }) + .option('temp-directory', { + describe: + 'The directory that is used to hold temporary files, such as the release spec template.', + type: 'string', + }) + .option('reset', { + describe: + 'Removes any cached files from a previous run that may have been created.', + type: 'boolean', + default: false, + }) + .help() + .strict() + .parse(); +} diff --git a/src/editor.test.ts b/src/editor.test.ts new file mode 100644 index 0000000..db5fda3 --- /dev/null +++ b/src/editor.test.ts @@ -0,0 +1,115 @@ +import { when } from 'jest-when'; +import { determineEditor } from './editor'; +import * as envModule from './env'; +import * as miscUtils from './misc-utils'; + +jest.mock('./env'); +jest.mock('./misc-utils'); + +describe('editor', () => { + describe('determineEditor', () => { + it('returns information about the editor from EDITOR if it resolves to an executable', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue('/path/to/resolved-editor'); + + expect(await determineEditor()).toStrictEqual({ + path: '/path/to/resolved-editor', + args: [], + }); + }); + + it('falls back to VSCode if it exists and if EDITOR does not point to an executable', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockResolvedValue('/path/to/code'); + + expect(await determineEditor()).toStrictEqual({ + path: '/path/to/code', + args: ['--wait'], + }); + }); + + it('returns null if resolving EDITOR returns null and resolving VSCode returns null', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if resolving EDITOR returns null and resolving VSCode throws', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockResolvedValue(null) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if resolving EDITOR throws and resolving VSCode returns null', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockRejectedValue(new Error('some error')) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if resolving EDITOR throws and resolving VSCode throws', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: 'editor', TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('editor') + .mockRejectedValue(new Error('some error')) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if EDITOR is unset and resolving VSCode returns null', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined, TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('code') + .mockResolvedValue(null); + + expect(await determineEditor()).toBeNull(); + }); + + it('returns null if EDITOR is unset and resolving VSCode throws', async () => { + jest + .spyOn(envModule, 'getEnvironmentVariables') + .mockReturnValue({ EDITOR: undefined, TODAY: undefined }); + when(jest.spyOn(miscUtils, 'resolveExecutable')) + .calledWith('code') + .mockRejectedValue(new Error('some error')); + + expect(await determineEditor()).toBeNull(); + }); + }); +}); diff --git a/src/editor.ts b/src/editor.ts new file mode 100644 index 0000000..63b56f8 --- /dev/null +++ b/src/editor.ts @@ -0,0 +1,56 @@ +import { getEnvironmentVariables } from './env'; +import { debug, resolveExecutable } from './misc-utils'; + +/** + * Information about the editor present on the user's computer. + * + * @property path - The path to the executable representing the editor. + * @property args - Command-line arguments to pass to the executable when + * calling it. + */ +export interface Editor { + path: string; + args: string[]; +} + +/** + * Looks for an executable that represents a code editor on your computer. Tries + * the `EDITOR` environment variable first, falling back to the executable that + * represents VSCode (`code`). + * + * @returns A promise that contains information about the found editor (path and + * arguments), or null otherwise. + */ +export async function determineEditor(): Promise { + let executablePath: string | null = null; + const executableArgs: string[] = []; + const { EDITOR } = getEnvironmentVariables(); + + if (EDITOR !== undefined) { + try { + executablePath = await resolveExecutable(EDITOR); + } catch (error) { + debug( + `Could not resolve executable ${EDITOR} (${error}), falling back to VSCode`, + ); + } + } + + if (executablePath === null) { + try { + executablePath = await resolveExecutable('code'); + // Waits until the file is closed before returning + executableArgs.push('--wait'); + } catch (error) { + debug( + `Could not resolve path to VSCode: ${error}, continuing regardless`, + ); + } + } + + if (executablePath !== null) { + return { path: executablePath, args: executableArgs }; + } + + return null; +} diff --git a/src/env.test.ts b/src/env.test.ts new file mode 100644 index 0000000..c40dbc6 --- /dev/null +++ b/src/env.test.ts @@ -0,0 +1,28 @@ +import { getEnvironmentVariables } from './env'; + +describe('env', () => { + describe('getEnvironmentVariables', () => { + let existingProcessEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + existingProcessEnv = { ...process.env }; + }); + + afterEach(() => { + Object.keys(existingProcessEnv).forEach((key) => { + process.env[key] = existingProcessEnv[key]; + }); + }); + + it('returns only the environment variables from process.env that we use in this tool', () => { + process.env.EDITOR = 'editor'; + process.env.TODAY = 'today'; + process.env.EXTRA = 'extra'; + + expect(getEnvironmentVariables()).toStrictEqual({ + EDITOR: 'editor', + TODAY: 'today', + }); + }); + }); +}); diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..22d8055 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,16 @@ +interface Env { + EDITOR: string | undefined; + TODAY: string | undefined; +} + +/** + * Returns all of the environment variables that this tool uses. + * + * @returns An object with a selection of properties from `process.env` that + * this tool needs to access, whether their values are defined or not. + */ +export function getEnvironmentVariables(): Env { + return ['EDITOR', 'TODAY'].reduce((object, key) => { + return { ...object, [key]: process.env[key] }; + }, {} as Env); +} diff --git a/src/fs.test.ts b/src/fs.test.ts new file mode 100644 index 0000000..f92f792 --- /dev/null +++ b/src/fs.test.ts @@ -0,0 +1,286 @@ +import fs from 'fs'; +import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; +import { when } from 'jest-when'; +import * as actionUtils from '@metamask/action-utils'; +import { withSandbox } from '../tests/unit/helpers'; +import { + readFile, + writeFile, + readJsonObjectFile, + writeJsonFile, + fileExists, + ensureDirectoryPathExists, + removeFile, +} from './fs'; + +jest.mock('@metamask/action-utils'); + +const promisifiedRimraf = util.promisify(rimraf); + +describe('fs', () => { + describe('readFile', () => { + it('reads the contents of the given file as a UTF-8-encoded string', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test'); + + await fs.promises.writeFile(filePath, 'some content 😄'); + + expect(await readFile(filePath)).toStrictEqual('some content 😄'); + }); + }); + + it('re-throws any error that occurs as a new error that points to the original', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent'); + + await expect(readFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not read file '${filePath}'`, + cause: expect.objectContaining({ + message: `ENOENT: no such file or directory, open '${filePath}'`, + }), + }), + ); + }); + }); + }); + + describe('writeFile', () => { + it('writes the given data to the given file', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test'); + + await writeFile(filePath, 'some content 😄'); + + expect(await fs.promises.readFile(filePath, 'utf8')).toStrictEqual( + 'some content 😄', + ); + }); + }); + + it('re-throws any error that occurs as a new error that points to the original', async () => { + await withSandbox(async (sandbox) => { + await promisifiedRimraf(sandbox.directoryPath); + const filePath = path.join(sandbox.directoryPath, 'test'); + + await expect(writeFile(filePath, 'some content 😄')).rejects.toThrow( + expect.objectContaining({ + message: `Could not write file '${filePath}'`, + cause: expect.objectContaining({ + message: `ENOENT: no such file or directory, open '${filePath}'`, + }), + }), + ); + }); + }); + }); + + describe('readJsonObjectFile', () => { + it('uses readJsonObjectFile from @metamask/action-utils to parse the contents of the given JSON file as an object', async () => { + const filePath = '/some/file'; + when(jest.spyOn(actionUtils, 'readJsonObjectFile')) + .calledWith(filePath) + .mockResolvedValue({ some: 'object' }); + + expect(await readJsonObjectFile(filePath)).toStrictEqual({ + some: 'object', + }); + }); + + it('re-throws any error that occurs as a new error that points to the original', async () => { + const filePath = '/some/file'; + const error = new Error('oops'); + when(jest.spyOn(actionUtils, 'readJsonObjectFile')) + .calledWith(filePath) + .mockRejectedValue(error); + + await expect(readJsonObjectFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not read JSON file '${filePath}'`, + cause: error, + }), + ); + }); + }); + + describe('writeJsonFile', () => { + it('uses writeJsonFile from @metamask/action-utils to write the given object to the given file as JSON', async () => { + const filePath = '/some/file'; + when(jest.spyOn(actionUtils, 'writeJsonFile')) + .calledWith(filePath, { some: 'object' }) + .mockResolvedValue(undefined); + + expect(await writeJsonFile(filePath, { some: 'object' })).toBeUndefined(); + }); + + it('re-throws any error that occurs as a new error that points to the original', async () => { + const filePath = '/some/file'; + const error = new Error('oops'); + when(jest.spyOn(actionUtils, 'writeJsonFile')) + .calledWith(filePath, { some: 'object' }) + .mockRejectedValue(error); + + await expect(writeJsonFile(filePath, { some: 'object' })).rejects.toThrow( + expect.objectContaining({ + message: `Could not write JSON file '${filePath}'`, + cause: error, + }), + ); + }); + }); + + describe('fileExists', () => { + it('returns true if the given path refers to an existing file', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'test'); + await fs.promises.writeFile(filePath, 'some content'); + + expect(await fileExists(filePath)).toBe(true); + }); + }); + + it('returns false if the given path refers to something that is not a file', async () => { + await withSandbox(async (sandbox) => { + const dirPath = path.join(sandbox.directoryPath, 'test'); + await fs.promises.mkdir(dirPath); + + expect(await fileExists(dirPath)).toBe(false); + }); + }); + + it('returns false if the given path does not refer to any existing entry', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'nonexistent'); + + expect(await fileExists(filePath)).toBe(false); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const entryPath = '/some/file'; + const error: any = new Error('oops'); + error.code = 'ESOMETHING'; + error.stack = 'some stack'; + when(jest.spyOn(fs.promises, 'stat')) + .calledWith(entryPath) + .mockRejectedValue(error); + + await expect(fileExists(entryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not determine if file exists '${entryPath}'`, + cause: error, + }), + ); + }); + + it('re-throws any error that occurs as a new error that points to the original', async () => { + const entryPath = '/some/file'; + const error = new Error('oops'); + when(jest.spyOn(fs.promises, 'stat')) + .calledWith(entryPath) + .mockRejectedValue(error); + + await expect(fileExists(entryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not determine if file exists '${entryPath}'`, + cause: error, + }), + ); + }); + }); + + describe('ensureDirectoryPathExists', () => { + it('creates directories leading up to and including the given path', async () => { + await withSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'foo', + 'bar', + 'baz', + ); + + await ensureDirectoryPathExists(directoryPath); + + await expect( + fs.promises.readdir(path.join(sandbox.directoryPath, 'foo')), + ).toResolve(); + await expect( + fs.promises.readdir(path.join(sandbox.directoryPath, 'foo', 'bar')), + ).toResolve(); + await expect( + fs.promises.readdir( + path.join(sandbox.directoryPath, 'foo', 'bar', 'baz'), + ), + ).toResolve(); + }); + }); + + it('does not throw an error, returning undefined, if the given directory already exists', async () => { + await withSandbox(async (sandbox) => { + const directoryPath = path.join( + sandbox.directoryPath, + 'foo', + 'bar', + 'baz', + ); + await fs.promises.mkdir(path.join(sandbox.directoryPath, 'foo')); + await fs.promises.mkdir(path.join(sandbox.directoryPath, 'foo', 'bar')); + await fs.promises.mkdir( + path.join(sandbox.directoryPath, 'foo', 'bar', 'baz'), + ); + + await expect(ensureDirectoryPathExists(directoryPath)).toResolve(); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const directoryPath = '/some/directory'; + const error = new Error('oops'); + when(jest.spyOn(fs.promises, 'mkdir')) + .calledWith(directoryPath, { recursive: true }) + .mockRejectedValue(error); + + await expect(ensureDirectoryPathExists(directoryPath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not create directory path '${directoryPath}'`, + cause: error, + }), + ); + }); + }); + + describe('removeFile', () => { + it('removes the file at the given path', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'foo'); + await fs.promises.writeFile(filePath, 'some content'); + + expect(await removeFile(filePath)).toBeUndefined(); + }); + }); + + it('does nothing if the given file does not exist', async () => { + await withSandbox(async (sandbox) => { + const filePath = path.join(sandbox.directoryPath, 'foo'); + expect(await removeFile(filePath)).toBeUndefined(); + }); + }); + + it('re-throws any error that occurs, assigning it the same code, a wrapped message, and a new stack', async () => { + const filePath = '/some/file'; + const error = new Error('oops'); + when(jest.spyOn(fs.promises, 'rm')) + .calledWith(filePath, { force: true }) + .mockRejectedValue(error); + + await expect(removeFile(filePath)).rejects.toThrow( + expect.objectContaining({ + message: `Could not remove file '${filePath}'`, + cause: error, + }), + ); + }); + }); +}); diff --git a/src/fs.ts b/src/fs.ts new file mode 100644 index 0000000..c369507 --- /dev/null +++ b/src/fs.ts @@ -0,0 +1,138 @@ +import fs from 'fs'; +import { + readJsonObjectFile as underlyingReadJsonObjectFile, + writeJsonFile as underlyingWriteJsonFile, +} from '@metamask/action-utils'; +import { wrapError, isErrorWithCode } from './misc-utils'; + +/** + * Reads the file at the given path, assuming its content is encoded as UTF-8. + * + * @param filePath - The path to the file. + * @returns The content of the file. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function readFile(filePath: string): Promise { + try { + return await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + throw wrapError(`Could not read file '${filePath}'`, error); + } +} + +/** + * Writes content to the file at the given path. + * + * @param filePath - The path to the file. + * @param content - The new content of the file. + * @throws An error with a stack trace if writing fails in any way. + */ +export async function writeFile( + filePath: string, + content: string, +): Promise { + try { + await fs.promises.writeFile(filePath, content); + } catch (error) { + throw wrapError(`Could not write file '${filePath}'`, error); + } +} + +/** + * Reads the assumed JSON file at the given path, attempts to parse it, and + * returns the resulting object. + * + * Throws if failing to read or parse, or if the parsed JSON value is not a + * plain object. + * + * @param filePath - The path segments pointing to the JSON file. Will be passed + * to path.join(). + * @returns The object corresponding to the parsed JSON file. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function readJsonObjectFile( + filePath: string, +): Promise> { + try { + return await underlyingReadJsonObjectFile(filePath); + } catch (error) { + throw wrapError(`Could not read JSON file '${filePath}'`, error); + } +} + +/** + * Attempts to write the given JSON-like value to the file at the given path. + * Adds a newline to the end of the file. + * + * @param filePath - The path to write the JSON file to, including the file + * itself. + * @param jsonValue - The JSON-like value to write to the file. Make sure that + * JSON.stringify can handle it. + * @throws An error with a stack trace if writing fails in any way. + */ +export async function writeJsonFile( + filePath: string, + jsonValue: unknown, +): Promise { + try { + await underlyingWriteJsonFile(filePath, jsonValue); + } catch (error) { + throw wrapError(`Could not write JSON file '${filePath}'`, error); + } +} + +/** + * Tests the given path to determine whether it represents a file. + * + * @param entryPath - The path to a file (or directory) on the filesystem. + * @returns A promise for true if the file exists or false otherwise. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function fileExists(entryPath: string): Promise { + try { + const stats = await fs.promises.stat(entryPath); + return stats.isFile(); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + return false; + } + + throw wrapError(`Could not determine if file exists '${entryPath}'`, error); + } +} + +/** + * Creates the given directory along with any directories leading up to the + * directory. If the directory already exists, this is a no-op. + * + * @param directoryPath - The path to the desired directory. + * @returns What `fs.promises.mkdir` returns. + * @throws An error with a stack trace if reading fails in any way. + */ +export async function ensureDirectoryPathExists( + directoryPath: string, +): Promise { + try { + return await fs.promises.mkdir(directoryPath, { recursive: true }); + } catch (error) { + throw wrapError( + `Could not create directory path '${directoryPath}'`, + error, + ); + } +} + +/** + * Removes the given file, if it exists. + * + * @param filePath - The path to the file. + * @returns What `fs.promises.rm` returns. + * @throws An error with a stack trace if removal fails in any way. + */ +export async function removeFile(filePath: string): Promise { + try { + return await fs.promises.rm(filePath, { force: true }); + } catch (error) { + throw wrapError(`Could not remove file '${filePath}'`, error); + } +} diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index 3845788..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toStrictEqual('Hello, Huey!'); - }); -}); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 6972c11..0000000 --- a/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} diff --git a/src/initial-parameters.test.ts b/src/initial-parameters.test.ts new file mode 100644 index 0000000..0420dc3 --- /dev/null +++ b/src/initial-parameters.test.ts @@ -0,0 +1,70 @@ +import os from 'os'; +import path from 'path'; +import { when } from 'jest-when'; +import { buildMockProject, buildMockPackage } from '../tests/unit/helpers'; +import { determineInitialParameters } from './initial-parameters'; +import * as commandLineArgumentsModule from './command-line-arguments'; +import * as projectModule from './project'; + +jest.mock('./command-line-arguments'); +jest.mock('./project'); + +describe('initial-parameters', () => { + describe('determineInitialParameters', () => { + it('returns an object that contains data necessary to run the workflow', async () => { + const project = buildMockProject(); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: '/path/to/temp', + reset: true, + }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + + const config = await determineInitialParameters( + ['arg1', 'arg2'], + '/path/to/somewhere', + ); + + expect(config).toStrictEqual({ + project, + tempDirectoryPath: '/path/to/temp', + reset: true, + }); + }); + + it('uses a default temporary directory based on the name of the package if no such directory was passed as an input', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('@foo/bar'), + }); + when(jest.spyOn(commandLineArgumentsModule, 'readCommandLineArguments')) + .calledWith(['arg1', 'arg2']) + .mockResolvedValue({ + projectDirectory: '/path/to/project', + tempDirectory: undefined, + reset: true, + }); + when(jest.spyOn(projectModule, 'readProject')) + .calledWith('/path/to/project') + .mockResolvedValue(project); + + const config = await determineInitialParameters( + ['arg1', 'arg2'], + '/path/to/somewhere', + ); + + expect(config).toStrictEqual({ + project, + tempDirectoryPath: path.join( + os.tmpdir(), + 'create-release-branch', + '@foo__bar', + ), + reset: true, + }); + }); + }); +}); diff --git a/src/initial-parameters.ts b/src/initial-parameters.ts new file mode 100644 index 0000000..5ccfd85 --- /dev/null +++ b/src/initial-parameters.ts @@ -0,0 +1,37 @@ +import os from 'os'; +import path from 'path'; +import { readCommandLineArguments } from './command-line-arguments'; +import { readProject, Project } from './project'; + +interface InitialParameters { + project: Project; + tempDirectoryPath: string; + reset: boolean; +} + +/** + * Reads the inputs given to this tool via `process.argv` and uses them to + * gather information about the project the tool can use to run. + * + * @param argv - The arguments to this executable. + * @param cwd - The directory in which this executable was run. + * @returns The initial parameters. + */ +export async function determineInitialParameters( + argv: string[], + cwd: string, +): Promise { + const inputs = await readCommandLineArguments(argv); + const projectDirectoryPath = path.resolve(cwd, inputs.projectDirectory); + const project = await readProject(projectDirectoryPath); + const tempDirectoryPath = + inputs.tempDirectory === undefined + ? path.join( + os.tmpdir(), + 'create-release-branch', + project.rootPackage.manifest.name.replace('/', '__'), + ) + : path.resolve(cwd, inputs.tempDirectory); + + return { project, tempDirectoryPath, reset: inputs.reset }; +} diff --git a/src/main.test.ts b/src/main.test.ts new file mode 100644 index 0000000..539275c --- /dev/null +++ b/src/main.test.ts @@ -0,0 +1,66 @@ +import fs from 'fs'; +import { buildMockProject } from '../tests/unit/helpers'; +import { main } from './main'; +import * as initialParametersModule from './initial-parameters'; +import * as monorepoWorkflowOperations from './monorepo-workflow-operations'; + +jest.mock('./initial-parameters'); +jest.mock('./monorepo-workflow-operations'); + +describe('main', () => { + it('executes the monorepo workflow if the project is a monorepo', async () => { + const project = buildMockProject({ isMonorepo: true }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + jest + .spyOn(initialParametersModule, 'determineInitialParameters') + .mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: false, + }); + const followMonorepoWorkflowSpy = jest + .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') + .mockResolvedValue(); + + await main({ + argv: [], + cwd: '/path/to/somewhere', + stdout, + stderr, + }); + + expect(followMonorepoWorkflowSpy).toHaveBeenCalledWith({ + project, + tempDirectoryPath: '/path/to/temp/directory', + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + }); + + it('executes the polyrepo workflow if the project is within a polyrepo', async () => { + const project = buildMockProject({ isMonorepo: false }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + jest + .spyOn(initialParametersModule, 'determineInitialParameters') + .mockResolvedValue({ + project, + tempDirectoryPath: '/path/to/temp/directory', + reset: false, + }); + const followMonorepoWorkflowSpy = jest + .spyOn(monorepoWorkflowOperations, 'followMonorepoWorkflow') + .mockResolvedValue(); + + await main({ + argv: [], + cwd: '/path/to/somewhere', + stdout, + stderr, + }); + + expect(followMonorepoWorkflowSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..66a0fe7 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,47 @@ +import type { WriteStream } from 'fs'; +import { determineInitialParameters } from './initial-parameters'; +import { followMonorepoWorkflow } from './monorepo-workflow-operations'; + +/** + * The main function for this tool. Designed to not access `process.argv`, + * `process.env`, `process.cwd()`, `process.stdout`, or `process.stderr` + * directly so as to be more easily testable. + * + * @param args - The arguments. + * @param args.argv - The name of this executable and its arguments (as obtained + * via `process.argv`). + * @param args.cwd - The directory in which this executable was run. + * @param args.stdout - A stream that can be used to write to standard out. + * @param args.stderr - A stream that can be used to write to standard error. + */ +export async function main({ + argv, + cwd, + stdout, + stderr, +}: { + argv: string[]; + cwd: string; + stdout: Pick; + stderr: Pick; +}) { + const { project, tempDirectoryPath, reset } = + await determineInitialParameters(argv, cwd); + + if (project.isMonorepo) { + stdout.write( + 'Project appears to have workspaces. Following monorepo workflow.\n', + ); + await followMonorepoWorkflow({ + project, + tempDirectoryPath, + firstRemovingExistingReleaseSpecification: reset, + stdout, + stderr, + }); + } else { + stdout.write( + 'Project does not appear to have any workspaces. Following polyrepo workflow.\n', + ); + } +} diff --git a/src/misc-utils.test.ts b/src/misc-utils.test.ts new file mode 100644 index 0000000..88effc1 --- /dev/null +++ b/src/misc-utils.test.ts @@ -0,0 +1,175 @@ +import * as whichModule from 'which'; +import * as execaModule from 'execa'; +import { + isErrorWithCode, + isErrorWithMessage, + isErrorWithStack, + wrapError, + resolveExecutable, + getStdoutFromCommand, + runCommand, +} from './misc-utils'; + +jest.mock('which'); +jest.mock('execa'); + +describe('misc-utils', () => { + describe('isErrorWithCode', () => { + it('returns true if given an object with a "code" property', () => { + expect(isErrorWithCode({ code: 'some code' })).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithCode(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithCode(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithCode(12345)).toBe(false); + }); + + it('returns false if given an object that does not have a "code" property', () => { + expect(isErrorWithCode({})).toBe(false); + }); + }); + + describe('isErrorWithMessage', () => { + it('returns true if given an object with a "message" property', () => { + expect(isErrorWithMessage({ message: 'some message' })).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithMessage(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithMessage(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithMessage(12345)).toBe(false); + }); + + it('returns false if given an object that does not have a "message" property', () => { + expect(isErrorWithMessage({})).toBe(false); + }); + }); + + describe('isErrorWithStack', () => { + it('returns true if given an object with a "stack" property', () => { + expect(isErrorWithStack({ stack: 'some stack' })).toBe(true); + }); + + it('returns false if given null', () => { + expect(isErrorWithStack(null)).toBe(false); + }); + + it('returns false if given undefined', () => { + expect(isErrorWithStack(undefined)).toBe(false); + }); + + it('returns false if given something that is not typeof object', () => { + expect(isErrorWithStack(12345)).toBe(false); + }); + + it('returns false if given an object that does not have a "stack" property', () => { + expect(isErrorWithStack({})).toBe(false); + }); + }); + + describe('wrapError', () => { + it('returns a new Error that links to the given Error', () => { + const originalError = new Error('oops'); + const newError = wrapError('Some message', originalError); + + expect(newError.message).toStrictEqual('Some message'); + expect(newError.cause).toBe(originalError); + }); + + it('copies over any "code" property that exists on the given Error', () => { + const originalError: any = new Error('oops'); + originalError.code = 'CODE'; + const newError: any = wrapError('Some message', originalError); + + expect(newError.code).toStrictEqual('CODE'); + }); + + it('returns a new Error which prefixes the given message', () => { + const newError = wrapError('Some message', 'Some original message'); + + expect(newError.message).toBe('Some message: Some original message'); + expect(newError.cause).toBeUndefined(); + }); + }); + + describe('resolveExecutable', () => { + it('returns the fullpath of the given executable as returned by "which"', async () => { + jest + .spyOn(whichModule, 'default') + .mockResolvedValue('/path/to/executable'); + + expect(await resolveExecutable('executable')).toStrictEqual( + '/path/to/executable', + ); + }); + + it('returns null if the given executable cannot be found', async () => { + jest + .spyOn(whichModule, 'default') + .mockRejectedValue(new Error('not found: executable')); + + expect(await resolveExecutable('executable')).toBeNull(); + }); + + it('throws the error that "which" throws if it is not a "not found" error', async () => { + jest + .spyOn(whichModule, 'default') + .mockRejectedValue(new Error('something else')); + + await expect(resolveExecutable('executable')).rejects.toThrow( + 'something else', + ); + }); + }); + + describe('getStdoutFromCommand', () => { + it('executes the given command and returns a version of the standard out from the command with whitespace trimmed', async () => { + const execaSpy = jest + .spyOn(execaModule, 'default') + // Typecast: It's difficult to provide a full return value for execa + .mockResolvedValue({ stdout: ' some output ' } as any); + + const output = await getStdoutFromCommand( + 'some command', + ['arg1', 'arg2'], + { all: true }, + ); + + expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], { + all: true, + }); + expect(output).toStrictEqual('some output'); + }); + }); + + describe('runCommand', () => { + it('runs the command, discarding its output', async () => { + const execaSpy = jest + .spyOn(execaModule, 'default') + // Typecast: It's difficult to provide a full return value for execa + .mockResolvedValue({ stdout: ' some output ' } as any); + + const result = await runCommand('some command', ['arg1', 'arg2'], { + all: true, + }); + + expect(execaSpy).toHaveBeenCalledWith('some command', ['arg1', 'arg2'], { + all: true, + }); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/misc-utils.ts b/src/misc-utils.ts new file mode 100644 index 0000000..779ea05 --- /dev/null +++ b/src/misc-utils.ts @@ -0,0 +1,155 @@ +import which from 'which'; +import execa from 'execa'; +import createDebug from 'debug'; +import { ErrorWithCause } from 'pony-cause'; +import { isObject } from '@metamask/utils'; + +export { isTruthyString } from '@metamask/action-utils'; +export { hasProperty, isNullOrUndefined } from '@metamask/utils'; +export { isObject }; + +/** + * A logger object for the implementation part of this project. + * + * @see The [debug](https://www.npmjs.com/package/debug) package. + */ +export const debug = createDebug('create-release-branch:impl'); + +/** + * Type guard for determining whether the given value is an instance of Error. + * For errors generated via `fs.promises`, `error instanceof Error` won't work, + * so we have to come up with another way of testing. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +function isError(error: unknown): error is Error { + return ( + error instanceof Error || + (isObject(error) && error.constructor.name === 'Error') + ); +} + +/** + * Type guard for determining whether the given value is an error object with a + * `code` property such as the type of error that Node throws for filesystem + * operations, etc. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +export function isErrorWithCode(error: unknown): error is { code: string } { + return typeof error === 'object' && error !== null && 'code' in error; +} + +/** + * Type guard for determining whether the given value is an error object with a + * `message` property, such as an instance of Error. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +export function isErrorWithMessage( + error: unknown, +): error is { message: string } { + return typeof error === 'object' && error !== null && 'message' in error; +} + +/** + * Type guard for determining whether the given value is an error object with a + * `stack` property, such as an instance of Error. + * + * @param error - The object to check. + * @returns True or false, depending on the result. + */ +export function isErrorWithStack(error: unknown): error is { stack: string } { + return typeof error === 'object' && error !== null && 'stack' in error; +} + +/** + * Builds a new error object, linking to the original error via the `cause` + * property if it is an Error. + * + * This function is useful to reframe error messages in general, but is + * _critical_ when interacting with any of Node's filesystem functions as + * provided via `fs.promises`, because these do not produce stack traces in the + * case of an I/O error (see ). + * + * @param message - The desired message of the new error. + * @param originalError - The error that you want to cover (either an Error or + * something throwable). + * @returns A new error object. + */ +export function wrapError(message: string, originalError: unknown) { + if (isError(originalError)) { + const error: any = new ErrorWithCause(message, { cause: originalError }); + + if (isErrorWithCode(originalError)) { + error.code = originalError.code; + } + + return error; + } + + return new Error(`${message}: ${originalError}`); +} + +/** + * Retrieves the real path of an executable via `which`. + * + * @param executablePath - The path to an executable. + * @returns The resolved path to the executable. + * @throws what `which` throws if it is not a "not found" error. + */ +export async function resolveExecutable( + executablePath: string, +): Promise { + try { + return await which(executablePath); + } catch (error) { + if ( + isErrorWithMessage(error) && + new RegExp(`^not found: ${executablePath}$`, 'u').test(error.message) + ) { + return null; + } + + throw error; + } +} + +/** + * Runs a command, retrieving the standard output with leading and trailing + * whitespace removed. + * + * @param command - The command to execute. + * @param args - The positional arguments to the command. + * @param options - The options to `execa`. + * @returns The standard output of the command. + * @throws An `execa` error object if the command fails in some way. + * @see `execa`. + */ +export async function getStdoutFromCommand( + command: string, + args?: readonly string[] | undefined, + options?: execa.Options | undefined, +): Promise { + return (await execa(command, args, options)).stdout.trim(); +} + +/** + * Runs a command, discarding its output. + * + * @param command - The command to execute. + * @param args - The positional arguments to the command. + * @param options - The options to `execa`. + * @throws An `execa` error object if the command fails in some way. + * @see `execa`. + */ +export async function runCommand( + command: string, + args?: readonly string[] | undefined, + options?: execa.Options | undefined, +): Promise { + await execa(command, args, options); +} diff --git a/src/monorepo-workflow-operations.test.ts b/src/monorepo-workflow-operations.test.ts new file mode 100644 index 0000000..2d225cf --- /dev/null +++ b/src/monorepo-workflow-operations.test.ts @@ -0,0 +1,1816 @@ +import fs from 'fs'; +import path from 'path'; +import { SemVer } from 'semver'; +import { + withSandbox, + buildMockPackage, + buildMockProject, +} from '../tests/unit/helpers'; +import { followMonorepoWorkflow } from './monorepo-workflow-operations'; +import * as editorModule from './editor'; +import * as envModule from './env'; +import * as packageModule from './package'; +import type { Package } from './package'; +import type { ValidatedPackageManifest } from './package-manifest'; +import type { Project } from './project'; +import * as releaseSpecificationModule from './release-specification'; +import * as workflowOperations from './workflow-operations'; + +jest.mock('./editor'); +jest.mock('./env'); +jest.mock('./package'); +jest.mock('./release-specification'); +jest.mock('./workflow-operations'); + +/** + * Given a Promise type, returns the type inside. + */ +type UnwrapPromise = T extends Promise ? U : never; + +describe('monorepo-workflow-operations', () => { + describe('followMonorepoWorkflow', () => { + describe('when firstRemovingExistingReleaseSpecification is true', () => { + describe('when a release spec file does not already exist', () => { + describe('when an editor can be determined', () => { + describe('when the editor command completes successfully', () => { + it('generates a release spec, waits for the user to edit it, then applies it to the monorepo', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + b: buildMockPackage('b', '1.0.0', { + manifest: { + private: false, + }, + }), + c: buildMockPackage('c', '1.0.0', { + manifest: { + private: false, + }, + }), + d: buildMockPackage('d', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + updatePackageSpy, + } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts + .major, + b: releaseSpecificationModule.IncrementableVersionParts + .minor, + c: releaseSpecificationModule.IncrementableVersionParts + .patch, + d: new SemVer('1.2.3'), + }, + }, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + project, + packageReleasePlan: { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { + project, + packageReleasePlan: { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(3, { + project, + packageReleasePlan: { + package: project.workspacePackages.b, + newVersion: '1.1.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(4, { + project, + packageReleasePlan: { + package: project.workspacePackages.c, + newVersion: '1.0.1', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(5, { + project, + packageReleasePlan: { + package: project.workspacePackages.d, + newVersion: '1.2.3', + shouldUpdateChangelog: true, + }, + stderr, + }); + }); + }); + + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { captureChangesInReleaseBranchSpy } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts + .major, + }, + }, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + project, + { + releaseName: '2022-06-12', + packages: [ + { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + ], + }, + ); + }); + }); + + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + await expect( + fs.promises.readFile( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + 'utf8', + ), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.2'), + }, + }, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, + ); + }); + }); + + it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( + expect.anything(), + ); + }); + }); + }); + + describe('when the editor command does not complete successfully', () => { + it('removes the release spec file', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + }); + jest + .spyOn( + releaseSpecificationModule, + 'waitForUserToEditReleaseSpecification', + ) + .mockRejectedValue(new Error('oops')); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + await expect( + fs.promises.readFile( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + 'utf8', + ), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + }); + }); + + describe('when an editor cannot be determined', () => { + it('merely generates a release spec and nothing more', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + validateReleaseSpecificationSpy, + updatePackageSpy, + captureChangesInReleaseBranchSpy, + } = mockDependencies({ + determineEditor: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.toHaveBeenCalled(); + expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); + expect(updatePackageSpy).not.toHaveBeenCalled(); + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when a release spec file already exists', () => { + describe('when an editor can be determined', () => { + describe('when the editor command completes successfully', () => { + it('re-generates the release spec, waits for the user to edit it, then applies it to the monorepo', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + updatePackageSpy, + } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts + .major, + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + project, + packageReleasePlan: { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { + project, + packageReleasePlan: { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + }); + }); + + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { captureChangesInReleaseBranchSpy } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts + .major, + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + project, + { + releaseName: '2022-06-12', + packages: [ + { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + ], + }, + ); + }); + }); + + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + await expect( + fs.promises.readFile(releaseSpecPath, 'utf8'), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.2'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, + ); + }); + }); + + it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( + expect.anything(), + ); + }); + }); + }); + + describe('when the editor command does not complete successfully', () => { + it('removes the release spec file', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + }); + jest + .spyOn( + releaseSpecificationModule, + 'waitForUserToEditReleaseSpecification', + ) + .mockRejectedValue(new Error('oops')); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + await expect( + fs.promises.readFile(releaseSpecPath, 'utf8'), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + }); + }); + + describe('when an editor cannot be determined', () => { + it('merely re-generates a release spec and nothing more', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + validateReleaseSpecificationSpy, + updatePackageSpy, + captureChangesInReleaseBranchSpy, + } = mockDependencies({ + determineEditor: null, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: true, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.toHaveBeenCalled(); + expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); + expect(updatePackageSpy).not.toHaveBeenCalled(); + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + }); + }); + }); + + describe('when firstRemovingExistingReleaseSpecification is false', () => { + describe('when a release spec file does not already exist', () => { + describe('when an editor can be determined', () => { + describe('when the editor command completes successfully', () => { + it('generates a release spec, waits for the user to edit it, then applies it to the monorepo', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + b: buildMockPackage('b', '1.0.0', { + manifest: { + private: false, + }, + }), + c: buildMockPackage('c', '1.0.0', { + manifest: { + private: false, + }, + }), + d: buildMockPackage('d', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + updatePackageSpy, + } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts + .major, + b: releaseSpecificationModule.IncrementableVersionParts + .minor, + c: releaseSpecificationModule.IncrementableVersionParts + .patch, + d: new SemVer('1.2.3'), + }, + }, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + project, + packageReleasePlan: { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { + project, + packageReleasePlan: { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(3, { + project, + packageReleasePlan: { + package: project.workspacePackages.b, + newVersion: '1.1.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(4, { + project, + packageReleasePlan: { + package: project.workspacePackages.c, + newVersion: '1.0.1', + shouldUpdateChangelog: true, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(5, { + project, + packageReleasePlan: { + package: project.workspacePackages.d, + newVersion: '1.2.3', + shouldUpdateChangelog: true, + }, + stderr, + }); + }); + }); + + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { captureChangesInReleaseBranchSpy } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts + .major, + }, + }, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + project, + { + releaseName: '2022-06-12', + packages: [ + { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + ], + }, + ); + }); + }); + + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + await expect( + fs.promises.readFile( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + 'utf8', + ), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.2'), + }, + }, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, + ); + }); + }); + + it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect( + await fs.promises.stat( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + ), + ).toStrictEqual(expect.anything()); + }); + }); + }); + + describe('when the editor command does not complete successfully', () => { + it('removes the release spec file', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + }); + jest + .spyOn( + releaseSpecificationModule, + 'waitForUserToEditReleaseSpecification', + ) + .mockRejectedValue(new Error('oops')); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + await expect( + fs.promises.readFile( + path.join(sandbox.directoryPath, 'RELEASE_SPEC'), + 'utf8', + ), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + }); + }); + + describe('when an editor cannot be determined', () => { + it('merely generates a release spec and nothing more', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + validateReleaseSpecificationSpy, + updatePackageSpy, + captureChangesInReleaseBranchSpy, + } = mockDependencies({ + determineEditor: null, + }); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.toHaveBeenCalled(); + expect(validateReleaseSpecificationSpy).not.toHaveBeenCalled(); + expect(updatePackageSpy).not.toHaveBeenCalled(); + expect(captureChangesInReleaseBranchSpy).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('when a release spec file already exists', () => { + it('does not re-generate the release spec, but applies it to the monorepo', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + updatePackageSpy, + } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts.major, + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + expect( + generateReleaseSpecificationTemplateForMonorepoSpy, + ).not.toHaveBeenCalled(); + expect( + waitForUserToEditReleaseSpecificationSpy, + ).not.toHaveBeenCalled(); + expect(updatePackageSpy).toHaveBeenNthCalledWith(1, { + project, + packageReleasePlan: { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + stderr, + }); + expect(updatePackageSpy).toHaveBeenNthCalledWith(2, { + project, + packageReleasePlan: { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + stderr, + }); + }); + }); + + it('creates a new branch named after the generated release version', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + const { captureChangesInReleaseBranchSpy } = mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: releaseSpecificationModule.IncrementableVersionParts.major, + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + expect(captureChangesInReleaseBranchSpy).toHaveBeenCalledWith( + project, + { + releaseName: '2022-06-12', + packages: [ + { + package: project.rootPackage, + newVersion: '2022.6.12', + shouldUpdateChangelog: false, + }, + { + package: project.workspacePackages.a, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }, + ], + }, + ); + }); + }); + + it('removes the release spec file at the end', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject(); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }); + + await expect( + fs.promises.readFile(releaseSpecPath, 'utf8'), + ).rejects.toThrow(/^ENOENT: no such file or directory/u); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.0" as that is already the current version./u, + ); + }); + }); + + it("throws if a version specifier for a package within the edited release spec, when applied, would result in a backward change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.3', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.2'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow( + /^Could not update package "a" to "1.0.2" as it is less than the current version "1.0.3"./u, + ); + }); + }); + + it("does not remove the release spec file if a version specifier for a package within the edited release spec, when applied, would result in no change to the package's version", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockMonorepoProject({ + rootPackage: buildMockPackage('root', '2022.1.1', { + manifest: { + private: true, + workspaces: ['packages/*'], + }, + }), + workspacePackages: { + a: buildMockPackage('a', '1.0.0', { + manifest: { + private: false, + }, + }), + }, + }); + const stdout = fs.createWriteStream('/dev/null'); + const stderr = fs.createWriteStream('/dev/null'); + mockDependencies({ + determineEditor: { + path: '/some/editor', + args: [], + }, + getEnvironmentVariables: { + TODAY: '2022-06-12', + }, + validateReleaseSpecification: { + packages: { + a: new SemVer('1.0.0'), + }, + }, + }); + const releaseSpecPath = path.join( + sandbox.directoryPath, + 'RELEASE_SPEC', + ); + await fs.promises.writeFile(releaseSpecPath, 'release spec'); + + await expect( + followMonorepoWorkflow({ + project, + tempDirectoryPath: sandbox.directoryPath, + firstRemovingExistingReleaseSpecification: false, + stdout, + stderr, + }), + ).rejects.toThrow(expect.anything()); + + expect(await fs.promises.stat(releaseSpecPath)).toStrictEqual( + expect.anything(), + ); + }); + }); + }); + }); + }); +}); + +/** + * Builds a project for use in tests which represents a monorepo. + * + * @param overrides - The properties that will go into the object. + * @returns The mock Project object. + */ +function buildMockMonorepoProject(overrides: Partial = {}) { + return buildMockProject({ + rootPackage: buildMockMonorepoRootPackage(), + workspacePackages: {}, + ...overrides, + }); +} + +/** + * Builds a package for use in tests which is designed to be the root package of + * a monorepo. + * + * @param name - The name of the package. + * @param version - The version of the package, as a version string. + * @param overrides - The properties that will go into the object. + * @returns The mock Package object. + */ +function buildMockMonorepoRootPackage( + name = 'root', + version = '2022.1.1', + overrides: Omit, 'manifest'> & { + manifest?: Partial; + } = {}, +) { + const { manifest, ...rest } = overrides; + return buildMockPackage(name, version, { + manifest: { + private: true, + workspaces: ['packages/*'], + ...manifest, + }, + ...rest, + }); +} + +/** + * Mocks dependencies that `followMonorepoWorkflow` uses internally. + * + * @param args - The arguments to this function. + * @param args.determineEditor - The return value for `determineEditor`. + * @param args.getEnvironmentVariables - The return value for + * `getEnvironmentVariables`. + * @param args.generateReleaseSpecificationTemplateForMonorepo - The return + * value for `generateReleaseSpecificationTemplateForMonorepo`. + * @param args.waitForUserToEditReleaseSpecification - The return value for + * `waitForUserToEditReleaseSpecification`. + * @param args.validateReleaseSpecification - The return value for + * `validateReleaseSpecification`. + * @param args.updatePackage - The return value for `updatePackage`. + * @param args.captureChangesInReleaseBranch - The return value for + * `captureChangesInReleaseBranch`. + * @returns Jest spy objects for the aforementioned dependencies. + */ +function mockDependencies({ + determineEditor: determineEditorValue = null, + getEnvironmentVariables: getEnvironmentVariablesValue = {}, + generateReleaseSpecificationTemplateForMonorepo: + generateReleaseSpecificationTemplateForMonorepoValue = '{}', + waitForUserToEditReleaseSpecification: + waitForUserToEditReleaseSpecificationValue = undefined, + validateReleaseSpecification: validateReleaseSpecificationValue = { + packages: {}, + }, + updatePackage: updatePackageValue = undefined, + captureChangesInReleaseBranch: captureChangesInReleaseBranchValue = undefined, +}: { + determineEditor?: UnwrapPromise< + ReturnType + >; + getEnvironmentVariables?: Partial< + ReturnType + >; + generateReleaseSpecificationTemplateForMonorepo?: UnwrapPromise< + ReturnType< + typeof releaseSpecificationModule.generateReleaseSpecificationTemplateForMonorepo + > + >; + waitForUserToEditReleaseSpecification?: UnwrapPromise< + ReturnType< + typeof releaseSpecificationModule.waitForUserToEditReleaseSpecification + > + >; + validateReleaseSpecification?: UnwrapPromise< + ReturnType + >; + updatePackage?: UnwrapPromise>; + captureChangesInReleaseBranch?: UnwrapPromise< + ReturnType + >; +}) { + jest + .spyOn(editorModule, 'determineEditor') + .mockResolvedValue(determineEditorValue); + jest.spyOn(envModule, 'getEnvironmentVariables').mockReturnValue({ + EDITOR: undefined, + TODAY: undefined, + ...getEnvironmentVariablesValue, + }); + const generateReleaseSpecificationTemplateForMonorepoSpy = jest + .spyOn( + releaseSpecificationModule, + 'generateReleaseSpecificationTemplateForMonorepo', + ) + .mockResolvedValue(generateReleaseSpecificationTemplateForMonorepoValue); + const waitForUserToEditReleaseSpecificationSpy = jest + .spyOn(releaseSpecificationModule, 'waitForUserToEditReleaseSpecification') + .mockResolvedValue(waitForUserToEditReleaseSpecificationValue); + const validateReleaseSpecificationSpy = jest + .spyOn(releaseSpecificationModule, 'validateReleaseSpecification') + .mockResolvedValue(validateReleaseSpecificationValue); + const updatePackageSpy = jest + .spyOn(packageModule, 'updatePackage') + .mockResolvedValue(updatePackageValue); + const captureChangesInReleaseBranchSpy = jest + .spyOn(workflowOperations, 'captureChangesInReleaseBranch') + .mockResolvedValue(captureChangesInReleaseBranchValue); + + return { + generateReleaseSpecificationTemplateForMonorepoSpy, + waitForUserToEditReleaseSpecificationSpy, + validateReleaseSpecificationSpy, + updatePackageSpy, + captureChangesInReleaseBranchSpy, + }; +} diff --git a/src/monorepo-workflow-operations.ts b/src/monorepo-workflow-operations.ts new file mode 100644 index 0000000..ef2f0d5 --- /dev/null +++ b/src/monorepo-workflow-operations.ts @@ -0,0 +1,254 @@ +import type { WriteStream } from 'fs'; +import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; +import { debug } from './misc-utils'; +import { + ensureDirectoryPathExists, + fileExists, + removeFile, + writeFile, +} from './fs'; +import { determineEditor } from './editor'; +import { getEnvironmentVariables } from './env'; +import { updatePackage } from './package'; +import { Project } from './project'; +import { + generateReleaseSpecificationTemplateForMonorepo, + waitForUserToEditReleaseSpecification, + validateReleaseSpecification, + ReleaseSpecification, +} from './release-specification'; +import { SemVer } from './semver'; +import { + captureChangesInReleaseBranch, + PackageReleasePlan, + ReleasePlan, +} from './workflow-operations'; + +/** + * A promisified version of `rimraf`. + */ +const promisifiedRimraf = util.promisify(rimraf); + +/** + * Creates a date from the value of the `TODAY` environment variable, falling + * back to the current date if it is invalid or was not provided. This will be + * used to assign a name to the new release in the case of a monorepo with + * independent versions. + * + * @returns A date that represents "today". + */ +function getToday() { + const { TODAY } = getEnvironmentVariables(); + const parsedTodayTimestamp = + TODAY === undefined ? NaN : new Date(TODAY).getTime(); + return isNaN(parsedTodayTimestamp) + ? new Date() + : new Date(parsedTodayTimestamp); +} + +/** + * For a monorepo, the process works like this: + * + * - The tool generates a release spec template, listing the workspace packages + * in the project that have changed since the last release (or all of the + * packages if this would be the first release). + * - The tool then presents the template to the user so that they can specify + * the desired versions for each package. It first does this by attempting to + * locate an appropriate code editor on the user's computer (using the `EDITOR` + * environment variable if that is defined, otherwise `code` if it is present) + * and opening the file there, pausing while the user is editing the file. If no + * editor can be found, the tool provides the user with the path to the template + * so that they can edit it themselves, then exits. + * - However the user has edited the file, the tool will parse and validate the + * information in the file, then apply the desired changes to the monorepo. + * - Finally, once it has made the desired changes, the tool will create a Git + * commit that includes the changes, then create a branch using the current date + * as the name. + * + * @param options - The options. + * @param options.project - Information about the project. + * @param options.tempDirectoryPath - A directory in which to hold the generated + * release spec file. + * @param options.firstRemovingExistingReleaseSpecification - Sometimes it's + * possible for a release specification that was created in a previous run to + * stick around (due to an error). This will ensure that the file is removed + * first. + * @param options.stdout - A stream that can be used to write to standard out. + * @param options.stderr - A stream that can be used to write to standard error. + */ +export async function followMonorepoWorkflow({ + project, + tempDirectoryPath, + firstRemovingExistingReleaseSpecification, + stdout, + stderr, +}: { + project: Project; + tempDirectoryPath: string; + firstRemovingExistingReleaseSpecification: boolean; + stdout: Pick; + stderr: Pick; +}) { + const releaseSpecificationPath = path.join(tempDirectoryPath, 'RELEASE_SPEC'); + + if (firstRemovingExistingReleaseSpecification) { + await promisifiedRimraf(releaseSpecificationPath); + } + + if (await fileExists(releaseSpecificationPath)) { + stdout.write( + 'Release spec already exists. Picking back up from previous run.\n', + ); + } else { + const editor = await determineEditor(); + + const releaseSpecificationTemplate = + await generateReleaseSpecificationTemplateForMonorepo({ + project, + isEditorAvailable: editor !== undefined, + }); + await ensureDirectoryPathExists(tempDirectoryPath); + await writeFile(releaseSpecificationPath, releaseSpecificationTemplate); + + if (!editor) { + stdout.write( + `${[ + 'A template has been generated that specifies this release. Please open the following file in your editor of choice, then re-run this tool:', + `${releaseSpecificationPath}`, + ].join('\n\n')}\n`, + ); + return; + } + + try { + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + ); + } catch (error) { + await removeFile(releaseSpecificationPath); + throw error; + } + } + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + const releasePlan = await planRelease( + project, + releaseSpecification, + releaseSpecificationPath, + ); + await applyUpdatesToMonorepo(project, releasePlan, stderr); + await removeFile(releaseSpecificationPath); + await captureChangesInReleaseBranch(project, releasePlan); +} + +/** + * Uses the release specification to calculate the final versions of all of the + * packages that we want to update, as well as a new release name. + * + * @param project - Information about the whole project (e.g., names of packages + * and where they can found). + * @param releaseSpecification - A parsed version of the release spec entered by + * the user. + * @param releaseSpecificationPath - The path to the release specification file. + * @returns A promise for information about the new release. + */ +async function planRelease( + project: Project, + releaseSpecification: ReleaseSpecification, + releaseSpecificationPath: string, +): Promise { + const today = getToday(); + const newReleaseName = today.toISOString().replace(/T.+$/u, ''); + const newRootVersion = [ + today.getUTCFullYear(), + today.getUTCMonth() + 1, + today.getUTCDate(), + ].join('.'); + + const rootReleasePlan: PackageReleasePlan = { + package: project.rootPackage, + newVersion: newRootVersion, + shouldUpdateChangelog: false, + }; + + const workspaceReleasePlans: PackageReleasePlan[] = Object.keys( + releaseSpecification.packages, + ).map((packageName) => { + const pkg = project.workspacePackages[packageName]; + const versionSpecifier = releaseSpecification.packages[packageName]; + const currentVersion = pkg.manifest.version; + let newVersion: SemVer; + + if (versionSpecifier instanceof SemVer) { + const comparison = versionSpecifier.compare(currentVersion); + + if (comparison === 0) { + throw new Error( + [ + `Could not update package "${packageName}" to "${versionSpecifier}" as that is already the current version.`, + `The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this tool.`, + releaseSpecificationPath, + ].join('\n\n'), + ); + } else if (comparison < 0) { + throw new Error( + [ + `Could not update package "${packageName}" to "${versionSpecifier}" as it is less than the current version "${currentVersion}".`, + `The release spec file has been retained for you to make the necessary fixes. Once you've done this, re-run this tool.`, + releaseSpecificationPath, + ].join('\n\n'), + ); + } + + newVersion = versionSpecifier; + } else { + newVersion = new SemVer(currentVersion.toString()).inc(versionSpecifier); + } + + return { + package: pkg, + newVersion: newVersion.toString(), + shouldUpdateChangelog: true, + }; + }); + + return { + releaseName: newReleaseName, + packages: [rootReleasePlan, ...workspaceReleasePlans], + }; +} + +/** + * Bumps versions and updates changelogs of packages within the monorepo + * according to the release plan. + * + * @param project - Information about the whole project (e.g., names of packages + * and where they can found). + * @param releasePlan - Compiled instructions on how exactly to update the + * project in order to prepare a new release. + * @param stderr - A stream that can be used to write to standard error. + */ +async function applyUpdatesToMonorepo( + project: Project, + releasePlan: ReleasePlan, + stderr: Pick, +) { + await Promise.all( + releasePlan.packages.map(async (workspaceReleasePlan) => { + debug( + `Updating package ${workspaceReleasePlan.package.manifest.name}...`, + ); + await updatePackage({ + project, + packageReleasePlan: workspaceReleasePlan, + stderr, + }); + }), + ); +} diff --git a/src/package-manifest.test.ts b/src/package-manifest.test.ts new file mode 100644 index 0000000..1414c00 --- /dev/null +++ b/src/package-manifest.test.ts @@ -0,0 +1,233 @@ +import fs from 'fs'; +import path from 'path'; +import { SemVer } from 'semver'; +import { withSandbox } from '../tests/unit/helpers'; +import { readPackageManifest } from './package-manifest'; + +describe('package-manifest', () => { + describe('readPackageManifest', () => { + it('reads a minimal package manifest, expanding it by filling in values for optional fields', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + }), + ); + + expect(await readPackageManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: false, + }); + }); + }); + + it('reads a package manifest where "private" is true', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: true, + }), + ); + + expect(await readPackageManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: true, + }); + }); + }); + + it('reads a package manifest where "private" is false', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: false, + }), + ); + + expect(await readPackageManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: false, + }); + }); + }); + + it('reads a package manifest where optional fields are fully provided', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + workspaces: ['packages/*'], + private: true, + }), + ); + + expect(await readPackageManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: ['packages/*'], + private: true, + }); + }); + }); + + it('reads a package manifest where the "workspaces" field is provided but empty', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + workspaces: [], + }), + ); + + expect(await readPackageManifest(manifestPath)).toStrictEqual({ + name: 'foo', + version: new SemVer('1.2.3'), + workspaces: [], + private: false, + }); + }); + }); + + it('throws if "name" is not provided', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + version: '1.2.3', + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, + ); + }); + }); + + it('throws if "name" is an empty string', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: '', + version: '1.2.3', + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, + ); + }); + }); + + it('throws if "name" is not a string', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 12345, + version: '1.2.3', + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + `The value of "name" in the manifest located at "${sandbox.directoryPath}" must be a non-empty string`, + ); + }); + }); + + it('throws if "version" is not provided', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + 'The value of "version" in the manifest for "foo" must be a valid SemVer version string', + ); + }); + }); + + it('throws if "version" is not a SemVer-compatible version string', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: 12345, + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + 'The value of "version" in the manifest for "foo" must be a valid SemVer version string', + ); + }); + }); + + it('throws if "workspaces" is not an array of strings', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + workspaces: 12345, + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + 'The value of "workspaces" in the manifest for "foo" must be an array of non-empty strings (if present)', + ); + }); + }); + + it('throws if "private" is not a boolean', async () => { + await withSandbox(async (sandbox) => { + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + await fs.promises.writeFile( + manifestPath, + JSON.stringify({ + name: 'foo', + version: '1.2.3', + private: 'whatever', + }), + ); + + await expect(readPackageManifest(manifestPath)).rejects.toThrow( + 'The value of "private" in the manifest for "foo" must be true or false (if present)', + ); + }); + }); + }); +}); diff --git a/src/package-manifest.ts b/src/package-manifest.ts new file mode 100644 index 0000000..d25e858 --- /dev/null +++ b/src/package-manifest.ts @@ -0,0 +1,298 @@ +import path from 'path'; +import { + ManifestFieldNames as PackageManifestFieldNames, + ManifestDependencyFieldNames as PackageManifestDependenciesFieldNames, +} from '@metamask/action-utils'; +import { readJsonObjectFile } from './fs'; +import { isTruthyString } from './misc-utils'; +import { isValidSemver, SemVer } from './semver'; + +export { PackageManifestFieldNames, PackageManifestDependenciesFieldNames }; + +/** + * An unverified representation of the data in a package's `package.json`. + */ +export type UnvalidatedPackageManifest = Readonly>; + +/** + * A type-checked representation of the data in a package's `package.json`. + * + * @property name - The name of the package. + * @property version - The version of the package. + * @property private - Whether the package is private. + * @property workspaces - Paths to subpackages within the package. + * @property bundledDependencies - The set of packages that are expected to be + * bundled when publishing the package. + */ +export type ValidatedPackageManifest = { + readonly [PackageManifestFieldNames.Name]: string; + readonly [PackageManifestFieldNames.Version]: SemVer; + readonly [PackageManifestFieldNames.Private]: boolean; + readonly [PackageManifestFieldNames.Workspaces]: string[]; +} & Readonly< + Partial>> +>; + +/** + * Constructs a validation error message for a field within the manifest. + * + * @param args - The arguments. + * @param args.manifest - The manifest data that's invalid. + * @param args.parentDirectory - The directory of the package to which the + * manifest belongs. + * @param args.fieldName - The name of the field in the manifest. + * @param args.verbPhrase - Either the fact that the field is invalid or an + * explanation for why it is invalid. + * @returns The error message. + */ +function buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName, + verbPhrase, +}: { + manifest: UnvalidatedPackageManifest; + parentDirectory: string; + fieldName: keyof UnvalidatedPackageManifest; + verbPhrase: string; +}) { + const subject = isTruthyString(manifest[PackageManifestFieldNames.Name]) + ? `The value of "${fieldName}" in the manifest for "${ + manifest[PackageManifestFieldNames.Name] + }"` + : `The value of "${fieldName}" in the manifest located at "${parentDirectory}"`; + return `${subject} ${verbPhrase}`; +} + +/** + * Object that includes checks for validating fields within a manifest + * along with error messages if those validations fail. + */ +const schemata = { + [PackageManifestFieldNames.Name]: { + validate: isTruthyString, + errorMessage: 'must be a non-empty string', + }, + [PackageManifestFieldNames.Version]: { + validate: isValidPackageManifestVersionField, + errorMessage: 'must be a valid SemVer version string', + }, + [PackageManifestFieldNames.Workspaces]: { + validate: isValidPackageManifestWorkspacesField, + errorMessage: 'must be an array of non-empty strings (if present)', + }, + [PackageManifestFieldNames.Private]: { + validate: isValidPackageManifestPrivateField, + errorMessage: 'must be true or false (if present)', + }, +}; + +/** + * Retrieves and validates the "name" field within the package manifest object. + * + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "name" field. + * @throws If the value of the field is not a truthy string. + */ +export function readPackageManifestNameField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): string { + const fieldName = PackageManifestFieldNames.Name; + const value = manifest[fieldName]; + const schema = schemata[fieldName]; + + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName: PackageManifestFieldNames.Name, + verbPhrase: schema.errorMessage, + }), + ); + } + + return value; +} + +/** + * Type guard to ensure that the value of the "version" field of a manifest is + * valid. + * + * @param version - The value to check. + * @returns Whether the version is a valid SemVer version string. + */ +function isValidPackageManifestVersionField( + version: unknown, +): version is string { + return isTruthyString(version) && isValidSemver(version); +} + +/** + * Retrieves and validates the "version" field within the package manifest + * object. + * + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "version" field wrapped in a SemVer object. + * @throws If the value of the field is not a valid SemVer version string. + */ +export function readPackageManifestVersionField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): SemVer { + const fieldName = PackageManifestFieldNames.Version; + const value = manifest[fieldName]; + const schema = schemata[fieldName]; + + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName: PackageManifestFieldNames.Version, + verbPhrase: schema.errorMessage, + }), + ); + } + + return new SemVer(value); +} + +/** + * Type guard to ensure that the value of the "workspaces" field of a manifest + * is valid. + * + * @param workspaces - The value to check. + * @returns Whether the value is an array of truthy strings. + */ +function isValidPackageManifestWorkspacesField( + workspaces: unknown, +): workspaces is string[] | undefined { + return ( + workspaces === undefined || + (Array.isArray(workspaces) && + workspaces.every((workspace) => isTruthyString(workspace))) + ); +} + +/** + * Retrieves and validates the "workspaces" field within the package manifest + * object. + * + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "workspaces" field, or an empty array if no such + * field exists. + * @throws If the value of the field is not an array of truthy strings. + */ +export function readPackageManifestWorkspacesField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): string[] { + const fieldName = PackageManifestFieldNames.Workspaces; + const value = manifest[fieldName]; + const schema = schemata[fieldName]; + + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName, + verbPhrase: schema.errorMessage, + }), + ); + } + + return value ?? []; +} + +/** + * Type guard to ensure that the value of the "private" field of a manifest is + * valid. + * + * @param privateValue - The value to check. + * @returns Whether the value is undefined, true, or false. + */ +function isValidPackageManifestPrivateField( + privateValue: unknown, +): privateValue is boolean | undefined { + return ( + privateValue === undefined || + privateValue === true || + privateValue === false + ); +} + +/** + * Retrieves and validates the "private" field within the package manifest + * object. + * + * @param manifest - The manifest object. + * @param parentDirectory - The directory in which the manifest lives. + * @returns The value of the "private" field, or false if no such field exists. + * @throws If the value of the field is not true or false. + */ +export function readPackageManifestPrivateField( + manifest: UnvalidatedPackageManifest, + parentDirectory: string, +): boolean { + const fieldName = PackageManifestFieldNames.Private; + const value = manifest[fieldName]; + const schema = schemata[fieldName]; + + if (!schema.validate(value)) { + throw new Error( + buildPackageManifestFieldValidationErrorMessage({ + manifest, + parentDirectory, + fieldName, + verbPhrase: schema.errorMessage, + }), + ); + } + + return value ?? false; +} + +/** + * Reads the package manifest at the given path, verifying key data within the + * manifest. + * + * @param manifestPath - The path of the manifest file. + * @returns The correctly typed version of the manifest. + * @throws If key data within the manifest is missing (currently `name` and + * `version`) or the value of any other fields is unexpected. + */ +export async function readPackageManifest( + manifestPath: string, +): Promise { + const unvalidatedPackageManifest = await readJsonObjectFile(manifestPath); + const parentDirectory = path.dirname(manifestPath); + const name = readPackageManifestNameField( + unvalidatedPackageManifest, + parentDirectory, + ); + const version = readPackageManifestVersionField( + unvalidatedPackageManifest, + parentDirectory, + ); + const workspaces = readPackageManifestWorkspacesField( + unvalidatedPackageManifest, + parentDirectory, + ); + const privateValue = readPackageManifestPrivateField( + unvalidatedPackageManifest, + parentDirectory, + ); + + return { + [PackageManifestFieldNames.Name]: name, + [PackageManifestFieldNames.Version]: version, + [PackageManifestFieldNames.Workspaces]: workspaces, + [PackageManifestFieldNames.Private]: privateValue, + }; +} diff --git a/src/package.test.ts b/src/package.test.ts new file mode 100644 index 0000000..c694de0 --- /dev/null +++ b/src/package.test.ts @@ -0,0 +1,223 @@ +import fs from 'fs'; +import path from 'path'; +import { when } from 'jest-when'; +import * as autoChangelog from '@metamask/auto-changelog'; +import { + buildMockProject, + buildMockManifest, + withSandbox, +} from '../tests/unit/helpers'; +import { readPackage, updatePackage } from './package'; +import * as fsModule from './fs'; +import * as packageManifestModule from './package-manifest'; + +jest.mock('@metamask/auto-changelog'); +jest.mock('./package-manifest'); + +describe('package', () => { + describe('readPackage', () => { + it('reads information about the package located at the given directory', async () => { + const packageDirectoryPath = '/path/to/package'; + jest + .spyOn(packageManifestModule, 'readPackageManifest') + .mockResolvedValue(buildMockManifest()); + + const pkg = await readPackage(packageDirectoryPath); + + expect(pkg).toStrictEqual({ + directoryPath: packageDirectoryPath, + manifestPath: path.join(packageDirectoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath: path.join(packageDirectoryPath, 'CHANGELOG.md'), + }); + }); + }); + + describe('updatePackage', () => { + it('writes the planned version to the planned package', async () => { + await withSandbox(async (sandbox) => { + const project = { + directoryPath: '/path/to/project', + repositoryUrl: 'https://repo.url', + }; + const manifestPath = path.join(sandbox.directoryPath, 'package.json'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath, + manifest: buildMockManifest(), + changelogPath: path.join(sandbox.directoryPath, 'CHANGELOG.md'), + }, + newVersion: '2.0.0', + shouldUpdateChangelog: false, + }; + + await updatePackage({ project, packageReleasePlan }); + + const newManifest = JSON.parse( + await fs.promises.readFile(manifestPath, 'utf8'), + ); + expect(newManifest).toMatchObject({ + version: '2.0.0', + }); + }); + }); + + it('updates the changelog of the package if requested to do so and if the package has one', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + when(jest.spyOn(autoChangelog, 'updateChangelog')) + .calledWith({ + changelogContent: 'existing changelog', + currentVersion: '2.0.0', + isReleaseCandidate: true, + projectRootDirectory: sandbox.directoryPath, + repoUrl: 'https://repo.url', + }) + .mockResolvedValue('new changelog'); + await fs.promises.writeFile(changelogPath, 'existing changelog'); + + await updatePackage({ project, packageReleasePlan }); + + const newChangelogContent = await fs.promises.readFile( + changelogPath, + 'utf8', + ); + expect(newChangelogContent).toStrictEqual('new changelog'); + }); + }); + + it("throws if reading the package's changelog fails in an unexpected way", async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + jest.spyOn(fsModule, 'readFile').mockRejectedValue(new Error('oops')); + + await expect( + updatePackage({ project, packageReleasePlan }), + ).rejects.toThrow('oops'); + }); + }); + + it('does not throw but merely prints a warning if the package does not have a changelog', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + jest + .spyOn(autoChangelog, 'updateChangelog') + .mockResolvedValue('new changelog'); + + const result = await updatePackage({ project, packageReleasePlan }); + + expect(result).toBeUndefined(); + }); + }); + + it('does not update the changelog if updateChangelog returns nothing', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: true, + }; + when(jest.spyOn(autoChangelog, 'updateChangelog')) + .calledWith({ + changelogContent: 'existing changelog', + currentVersion: '2.0.0', + isReleaseCandidate: true, + projectRootDirectory: sandbox.directoryPath, + repoUrl: 'https://repo.url', + }) + .mockResolvedValue(undefined); + await fs.promises.writeFile(changelogPath, 'existing changelog'); + + await updatePackage({ project, packageReleasePlan }); + + const newChangelogContent = await fs.promises.readFile( + changelogPath, + 'utf8', + ); + expect(newChangelogContent).toStrictEqual('existing changelog'); + }); + }); + + it('does not update the changelog if not requested to do so', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + repositoryUrl: 'https://repo.url', + }); + const changelogPath = path.join(sandbox.directoryPath, 'CHANGELOG.md'); + const packageReleasePlan = { + package: { + directoryPath: sandbox.directoryPath, + manifestPath: path.join(sandbox.directoryPath, 'package.json'), + manifest: buildMockManifest(), + changelogPath, + }, + newVersion: '2.0.0', + shouldUpdateChangelog: false, + }; + when(jest.spyOn(autoChangelog, 'updateChangelog')) + .calledWith({ + changelogContent: 'existing changelog', + currentVersion: '2.0.0', + isReleaseCandidate: true, + projectRootDirectory: sandbox.directoryPath, + repoUrl: 'https://repo.url', + }) + .mockResolvedValue('new changelog'); + await fs.promises.writeFile(changelogPath, 'existing changelog'); + + await updatePackage({ project, packageReleasePlan }); + + const newChangelogContent = await fs.promises.readFile( + changelogPath, + 'utf8', + ); + expect(newChangelogContent).toStrictEqual('existing changelog'); + }); + }); + }); +}); diff --git a/src/package.ts b/src/package.ts new file mode 100644 index 0000000..26efc85 --- /dev/null +++ b/src/package.ts @@ -0,0 +1,142 @@ +import fs, { WriteStream } from 'fs'; +import path from 'path'; +import { updateChangelog } from '@metamask/auto-changelog'; +import { isErrorWithCode } from './misc-utils'; +import { readFile, writeFile, writeJsonFile } from './fs'; +import { + readPackageManifest, + ValidatedPackageManifest, +} from './package-manifest'; +import { Project } from './project'; +import { PackageReleasePlan } from './workflow-operations'; + +const MANIFEST_FILE_NAME = 'package.json'; +const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; + +/** + * Information about a package within a project. + * + * @property directoryPath - The path to the directory where the package is + * located. + * @property manifestPath - The path to the manifest file. + * @property manifest - The data extracted from the manifest. + * @property changelogPath - The path to the changelog file (which may or may + * not exist). + */ +export interface Package { + directoryPath: string; + manifestPath: string; + manifest: ValidatedPackageManifest; + changelogPath: string; +} + +/** + * Collects information about a package. + * + * @param packageDirectoryPath - The path to a package within a project. + * @returns Information about the package. + */ +export async function readPackage( + packageDirectoryPath: string, +): Promise { + const manifestPath = path.join(packageDirectoryPath, MANIFEST_FILE_NAME); + const changelogPath = path.join(packageDirectoryPath, CHANGELOG_FILE_NAME); + const validatedManifest = await readPackageManifest(manifestPath); + + return { + directoryPath: packageDirectoryPath, + manifestPath, + manifest: validatedManifest, + changelogPath, + }; +} + +/** + * Updates the changelog file of the given package using + * `@metamask/auto-changelog`. Assumes that the changelog file is located at the + * package root directory and named "CHANGELOG.md". + * + * @param args - The arguments. + * @param args.project - The project. + * @param args.packageReleasePlan - The release plan for a particular package in + * the project. + * @param args.stderr - A stream that can be used to write to standard error. + * @returns The result of writing to the changelog. + */ +async function updatePackageChangelog({ + project: { repositoryUrl }, + packageReleasePlan: { package: pkg, newVersion }, + stderr, +}: { + project: Pick; + packageReleasePlan: PackageReleasePlan; + stderr: Pick; +}): Promise { + let changelogContent; + + try { + changelogContent = await readFile(pkg.changelogPath); + } catch (error) { + if (isErrorWithCode(error) && error.code === 'ENOENT') { + stderr.write( + `${pkg.manifest.name} does not seem to have a changelog. Skipping.\n`, + ); + return; + } + + throw error; + } + + const newChangelogContent = await updateChangelog({ + changelogContent, + currentVersion: newVersion, + isReleaseCandidate: true, + projectRootDirectory: pkg.directoryPath, + repoUrl: repositoryUrl, + }); + + if (newChangelogContent) { + await writeFile(pkg.changelogPath, newChangelogContent); + } else { + stderr.write( + `Changelog for ${pkg.manifest.name} was not updated as there were no updates to make.`, + ); + } +} + +/** + * Updates the package as per the instructions in the given release plan by + * replacing the `version` field in the manifest and adding a new section to the + * changelog for the new version of the package. + * + * @param args - The project. + * @param args.project - The project. + * @param args.packageReleasePlan - The release plan for a particular package in the + * project. + * @param args.stderr - A stream that can be used to write to standard error. + * Defaults to /dev/null. + */ +export async function updatePackage({ + project, + packageReleasePlan, + stderr = fs.createWriteStream('/dev/null'), +}: { + project: Pick; + packageReleasePlan: PackageReleasePlan; + stderr?: Pick; +}): Promise { + const { + package: pkg, + newVersion, + shouldUpdateChangelog, + } = packageReleasePlan; + + await writeJsonFile(pkg.manifestPath, { + ...pkg.manifest, + version: newVersion, + }); + + if (shouldUpdateChangelog) { + await updatePackageChangelog({ project, packageReleasePlan, stderr }); + } +} diff --git a/src/project.test.ts b/src/project.test.ts new file mode 100644 index 0000000..95d7d7a --- /dev/null +++ b/src/project.test.ts @@ -0,0 +1,100 @@ +import fs from 'fs'; +import path from 'path'; +import { when } from 'jest-when'; +import { + buildMockManifest, + buildMockPackage, + withSandbox, +} from '../tests/unit/helpers'; +import { readProject } from './project'; +import * as packageModule from './package'; +import * as repoModule from './repo'; + +jest.mock('./package'); +jest.mock('./repo'); + +describe('project', () => { + describe('readProject', () => { + it('collects information about a monorepo project', async () => { + await withSandbox(async (sandbox) => { + const projectDirectoryPath = sandbox.directoryPath; + const projectRepositoryUrl = '/~https://github.com/some-org/some-repo'; + const rootPackage = buildMockPackage('root', { + directoryPath: projectDirectoryPath, + manifest: buildMockManifest({ + workspaces: ['packages/a', 'packages/subpackages/*'], + }), + }); + const workspacePackages = { + a: buildMockPackage('a', { + directoryPath: path.join(projectDirectoryPath, 'packages', 'a'), + manifest: buildMockManifest(), + }), + b: buildMockPackage('b', { + directoryPath: path.join( + projectDirectoryPath, + 'packages', + 'subpackages', + 'b', + ), + manifest: buildMockManifest(), + }), + }; + when(jest.spyOn(repoModule, 'getRepositoryHttpsUrl')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(projectRepositoryUrl); + when(jest.spyOn(packageModule, 'readPackage')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(rootPackage) + .calledWith(path.join(projectDirectoryPath, 'packages', 'a')) + .mockResolvedValue(workspacePackages.a) + .calledWith( + path.join(projectDirectoryPath, 'packages', 'subpackages', 'b'), + ) + .mockResolvedValue(workspacePackages.b); + await fs.promises.mkdir(path.join(projectDirectoryPath, 'packages')); + await fs.promises.mkdir( + path.join(projectDirectoryPath, 'packages', 'a'), + ); + await fs.promises.mkdir( + path.join(projectDirectoryPath, 'packages', 'subpackages'), + ); + await fs.promises.mkdir( + path.join(projectDirectoryPath, 'packages', 'subpackages', 'b'), + ); + + expect(await readProject(projectDirectoryPath)).toStrictEqual({ + directoryPath: projectDirectoryPath, + repositoryUrl: projectRepositoryUrl, + rootPackage, + workspacePackages, + isMonorepo: true, + }); + }); + }); + + it('collects information about a polyrepo project', async () => { + await withSandbox(async (sandbox) => { + const projectDirectoryPath = sandbox.directoryPath; + const projectRepositoryUrl = '/~https://github.com/some-org/some-repo'; + const rootPackage = buildMockPackage('root', { + directoryPath: projectDirectoryPath, + }); + when(jest.spyOn(repoModule, 'getRepositoryHttpsUrl')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(projectRepositoryUrl); + when(jest.spyOn(packageModule, 'readPackage')) + .calledWith(projectDirectoryPath) + .mockResolvedValue(rootPackage); + + expect(await readProject(projectDirectoryPath)).toStrictEqual({ + directoryPath: projectDirectoryPath, + repositoryUrl: projectRepositoryUrl, + rootPackage, + workspacePackages: {}, + isMonorepo: false, + }); + }); + }); + }); +}); diff --git a/src/project.ts b/src/project.ts new file mode 100644 index 0000000..5976130 --- /dev/null +++ b/src/project.ts @@ -0,0 +1,80 @@ +import util from 'util'; +import glob from 'glob'; +import { Package, readPackage } from './package'; +import { PackageManifestFieldNames } from './package-manifest'; +import { getRepositoryHttpsUrl } from './repo'; + +/** + * Represents the entire codebase on which this tool is operating. + * + * @property directoryPath - The directory in which the project lives. + * @property repositoryUrl - The public URL of the Git repository where the + * codebase for the project lives. + * @property rootPackage - Information about the root package (assuming that the + * project is a monorepo). + * @property workspacePackages - Information about packages that are referenced + * via workspaces (assuming that the project is a monorepo). + */ +export interface Project { + directoryPath: string; + repositoryUrl: string; + rootPackage: Package; + workspacePackages: Record; + isMonorepo: boolean; +} + +/** + * A promisified version of `glob`. + */ +const promisifiedGlob = util.promisify(glob); + +/** + * Collects information about a project. For a polyrepo, this information will + * only cover the project's `package.json` file; for a monorepo, it will cover + * `package.json` files for any workspaces that the monorepo defines. + * + * @param projectDirectoryPath - The path to the project. + * @returns An object that represents information about the project. + * @throws if the project does not contain a root `package.json` (polyrepo and + * monorepo) or if any of the workspaces specified in the root `package.json` do + * not have `package.json`s (monorepo only). + */ +export async function readProject( + projectDirectoryPath: string, +): Promise { + const repositoryUrl = await getRepositoryHttpsUrl(projectDirectoryPath); + const rootPackage = await readPackage(projectDirectoryPath); + + const workspaceDirectories = ( + await Promise.all( + rootPackage.manifest[PackageManifestFieldNames.Workspaces].map( + async (workspacePattern) => { + return await promisifiedGlob(workspacePattern, { + cwd: projectDirectoryPath, + absolute: true, + }); + }, + ), + ) + ).flat(); + + const workspacePackages = ( + await Promise.all( + workspaceDirectories.map(async (directory) => { + return await readPackage(directory); + }), + ) + ).reduce((obj, pkg) => { + return { ...obj, [pkg.manifest.name]: pkg }; + }, {} as Record); + + const isMonorepo = Object.keys(workspacePackages).length > 0; + + return { + directoryPath: projectDirectoryPath, + repositoryUrl, + rootPackage, + workspacePackages, + isMonorepo, + }; +} diff --git a/src/release-specification.test.ts b/src/release-specification.test.ts new file mode 100644 index 0000000..bbf3bad --- /dev/null +++ b/src/release-specification.test.ts @@ -0,0 +1,429 @@ +import fs from 'fs'; +import path from 'path'; +import { when } from 'jest-when'; +import { MockWritable } from 'stdio-mock'; +import YAML from 'yaml'; +import { SemVer } from 'semver'; +import { + withSandbox, + buildMockProject, + buildMockPackage, +} from '../tests/unit/helpers'; +import { + generateReleaseSpecificationTemplateForMonorepo, + waitForUserToEditReleaseSpecification, + validateReleaseSpecification, +} from './release-specification'; +import * as miscUtils from './misc-utils'; + +jest.mock('./misc-utils', () => { + return { + ...jest.requireActual('./misc-utils'), + runCommand: jest.fn(), + }; +}); + +describe('release-specification', () => { + describe('generateReleaseSpecificationTemplateForMonorepo', () => { + it('returns a YAML-encoded string which has a list of all workspace packages in the project', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + }, + }); + + const template = await generateReleaseSpecificationTemplateForMonorepo({ + project, + isEditorAvailable: true, + }); + + expect(template).toStrictEqual( + ` +# The following is a list of packages in monorepo. +# Please indicate the packages for which you want to create a new release +# by updating "null" (which does nothing) to one of the following: +# +# - "major" (if you want to bump the major part of the package's version) +# - "minor" (if you want to bump the minor part of the package's version) +# - "patch" (if you want to bump the patch part of the package's version) +# - an exact version with major, minor, and patch parts (e.g. "1.2.3") +# - null (to skip the package entirely) +# +# When you're finished making your selections, save this file and +# create-release-branch will continue automatically. + +packages: + a: null + b: null +`.slice(1), + ); + }); + + it('adjusts the instructions slightly if an editor is not available', async () => { + const project = buildMockProject({ + rootPackage: buildMockPackage('monorepo'), + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + }, + }); + + const template = await generateReleaseSpecificationTemplateForMonorepo({ + project, + isEditorAvailable: false, + }); + + expect(template).toStrictEqual( + ` +# The following is a list of packages in monorepo. +# Please indicate the packages for which you want to create a new release +# by updating "null" (which does nothing) to one of the following: +# +# - "major" (if you want to bump the major part of the package's version) +# - "minor" (if you want to bump the minor part of the package's version) +# - "patch" (if you want to bump the patch part of the package's version) +# - an exact version with major, minor, and patch parts (e.g. "1.2.3") +# - null (to skip the package entirely) +# +# When you're finished making your selections, save this file and then re-run +# create-release-branch. + +packages: + a: null + b: null +`.slice(1), + ); + }); + }); + + describe('waitForUserToEditReleaseSpecification', () => { + it('waits for the given editor command to complete successfully', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { + path: '/path/to/editor', + args: ['arg1', 'arg2'], + }; + when(jest.spyOn(miscUtils, 'runCommand')) + .calledWith( + '/path/to/editor', + ['arg1', 'arg2', releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ) + .mockResolvedValue(); + + expect( + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + ), + ).toBeUndefined(); + }); + + it('prints a message to standard out, but then removes it, if the editor command succeeds', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { path: '/path/to/editor', args: [] }; + const stdout = new MockWritable(); + when(jest.spyOn(miscUtils, 'runCommand')).mockResolvedValue(); + + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + stdout, + ); + + expect(stdout.data()).toStrictEqual([ + 'Waiting for the release spec to be edited...', + '\r\u001B[K', + ]); + }); + + it('still removes the message printed to standard out when the editor command fails', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { + path: '/path/to/editor', + args: ['arg1', 'arg2'], + }; + const stdout = new MockWritable(); + when(jest.spyOn(miscUtils, 'runCommand')) + .calledWith( + '/path/to/editor', + ['arg1', 'arg2', releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ) + .mockRejectedValue(new Error('oops')); + + try { + await waitForUserToEditReleaseSpecification( + releaseSpecificationPath, + editor, + stdout, + ); + } catch { + // ignore any error that occurs + } + + expect(stdout.data()).toStrictEqual([ + 'Waiting for the release spec to be edited...', + '\r\u001B[K', + ]); + }); + + it('throws if the given editor command fails', async () => { + const releaseSpecificationPath = '/path/to/release-spec'; + const editor = { + path: '/path/to/editor', + args: ['arg1', 'arg2'], + }; + const error = new Error('oops'); + when(jest.spyOn(miscUtils, 'runCommand')) + .calledWith( + '/path/to/editor', + ['arg1', 'arg2', releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ) + .mockRejectedValue(error); + + await expect( + waitForUserToEditReleaseSpecification(releaseSpecificationPath, editor), + ).rejects.toThrow( + expect.objectContaining({ + message: + 'Encountered an error while waiting for the release spec to be edited.', + cause: error, + }), + ); + }); + }); + + describe('validateReleaseSpecification', () => { + it('reads the release spec file and returns an expanded, typed version of its contents', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + c: buildMockPackage('c'), + d: buildMockPackage('d'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: 'minor', + c: 'patch', + d: '1.2.3', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: 'major', + b: 'minor', + c: 'patch', + d: new SemVer('1.2.3'), + }, + }); + }); + }); + + it('removes packages which have "null" as their version specifier', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + c: buildMockPackage('c'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'major', + b: null, + c: 'patch', + }, + }), + ); + + const releaseSpecification = await validateReleaseSpecification( + project, + releaseSpecificationPath, + ); + + expect(releaseSpecification).toStrictEqual({ + packages: { + a: 'major', + c: 'patch', + }, + }); + }); + }); + + it('throws if the release spec cannot be parsed as valid YAML', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile(releaseSpecificationPath, 'foo: "bar'); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + expect.objectContaining({ + message: expect.stringMatching( + /^Your release spec does not appear to be valid YAML\.\n/u, + ), + cause: expect.objectContaining({ + message: expect.stringMatching(/^Missing closing "quote/u), + }), + }), + ); + }); + }); + + it('throws if the release spec does not hold an object', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify(12345), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + /^Your release spec could not be processed because it needs to be an object/u, + ); + }); + }); + + it('throws if the release spec holds an object but it does not have a "packages" property', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject(); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ foo: 'bar' }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + /^Your release spec could not be processed because it needs to be an object/u, + ); + }); + }); + + it('throws if any of the keys in the "packages" property do not match the names of any workspace packages', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + foo: 'major', + bar: 'minor', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + new RegExp( + [ + '^Your release spec could not be processed due to the following issues:\n', + '- Line 2: "foo" is not a package in the project', + '- Line 3: "bar" is not a package in the project', + ].join('\n'), + 'u', + ), + ); + }); + }); + + it('throws if any of the values in the "packages" property are not valid version specifiers', async () => { + await withSandbox(async (sandbox) => { + const project = buildMockProject({ + workspacePackages: { + a: buildMockPackage('a'), + b: buildMockPackage('b'), + }, + }); + const releaseSpecificationPath = path.join( + sandbox.directoryPath, + 'release-spec', + ); + await fs.promises.writeFile( + releaseSpecificationPath, + YAML.stringify({ + packages: { + a: 'asdflksdaf', + b: '1.2...3.', + }, + }), + ); + + await expect( + validateReleaseSpecification(project, releaseSpecificationPath), + ).rejects.toThrow( + new RegExp( + [ + '^Your release spec could not be processed due to the following issues:\n', + '- Line 2: "asdflksdaf" is not a valid version specifier for package "a"', + ' \\(must be "major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1\\.2\\.3"\\)', + '- Line 3: "1.2\\.\\.\\.3\\." is not a valid version specifier for package "b"', + ' \\(must be "major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1\\.2\\.3"\\)', + ].join('\n'), + 'u', + ), + ); + }); + }); + }); +}); diff --git a/src/release-specification.ts b/src/release-specification.ts new file mode 100644 index 0000000..ef51ff0 --- /dev/null +++ b/src/release-specification.ts @@ -0,0 +1,287 @@ +import fs, { WriteStream } from 'fs'; +import YAML from 'yaml'; +import { Editor } from './editor'; +import { readFile } from './fs'; +import { + debug, + hasProperty, + wrapError, + isObject, + runCommand, +} from './misc-utils'; +import { Project } from './project'; +import { isValidSemver, semver, SemVer } from './semver'; + +/** + * The SemVer-compatible parts of a version string that can be bumped by this + * tool. + */ +export enum IncrementableVersionParts { + major = 'major', + minor = 'minor', + patch = 'patch', +} + +/** + * Describes how to update the version for a package, either by bumping a part + * of the version or by setting that version exactly. + */ +type VersionSpecifier = IncrementableVersionParts | SemVer; + +/** + * User-provided instructions for how to update this project in order to prepare + * it for a new release. + * + * @property packages - A mapping of package names to version specifiers. + */ +export interface ReleaseSpecification { + packages: Record; +} + +/** + * Generates a skeleton for a release specification, which describes how a + * project should be updated. + * + * @param args - The set of arguments to this function. + * @param args.project - Information about the project. + * @param args.isEditorAvailable - Whether or not an executable can be found on + * the user's computer to edit the release spec once it is generated. + * @returns The release specification template. + */ +export async function generateReleaseSpecificationTemplateForMonorepo({ + project: { rootPackage, workspacePackages }, + isEditorAvailable, +}: { + project: Project; + isEditorAvailable: boolean; +}) { + const afterEditingInstructions = isEditorAvailable + ? ` +# When you're finished making your selections, save this file and +# create-release-branch will continue automatically.`.trim() + : ` +# When you're finished making your selections, save this file and then re-run +# create-release-branch.`.trim(); + + const instructions = ` +# The following is a list of packages in ${rootPackage.manifest.name}. +# Please indicate the packages for which you want to create a new release +# by updating "null" (which does nothing) to one of the following: +# +# - "major" (if you want to bump the major part of the package's version) +# - "minor" (if you want to bump the minor part of the package's version) +# - "patch" (if you want to bump the patch part of the package's version) +# - an exact version with major, minor, and patch parts (e.g. "1.2.3") +# - null (to skip the package entirely) +# +${afterEditingInstructions} + `.trim(); + + const packages = Object.values(workspacePackages).reduce((obj, pkg) => { + return { ...obj, [pkg.manifest.name]: null }; + }, {}); + + return [instructions, YAML.stringify({ packages })].join('\n\n'); +} + +/** + * Launches the given editor to allow the user to update the release spec + * file. + * + * @param releaseSpecificationPath - The path to the release spec file. + * @param editor - Information about the editor. + * @param stdout - A stream that can be used to write to standard out. Defaults + * to /dev/null. + * @returns A promise that resolves when the user has completed editing the + * file, i.e. when the editor process completes. + */ +export async function waitForUserToEditReleaseSpecification( + releaseSpecificationPath: string, + editor: Editor, + stdout: Pick = fs.createWriteStream('/dev/null'), +) { + let caughtError: unknown; + + debug( + `Opening release spec file ${releaseSpecificationPath} with editor located at ${editor.path}...`, + ); + + const promiseForEditorCommand = runCommand( + editor.path, + [...editor.args, releaseSpecificationPath], + { + stdio: 'inherit', + shell: true, + }, + ); + + stdout.write('Waiting for the release spec to be edited...'); + + try { + await promiseForEditorCommand; + } catch (error) { + caughtError = error; + } + + // Clear the previous line + stdout.write('\r\u001B[K'); + + if (caughtError) { + throw wrapError( + 'Encountered an error while waiting for the release spec to be edited.', + caughtError, + ); + } +} + +/** + * Looks over the release spec that the user has edited to ensure that: + * + * 1. the names of all packages match those within the project; and + * 2. the version specifiers for each package are valid. + * + * @param project - Information about the whole project (e.g., names of packages + * and where they can found). + * @param releaseSpecificationPath - The path to the release spec file. + * @returns The validated release spec. + * @throws If there are any issues with the file. + */ +export async function validateReleaseSpecification( + project: Project, + releaseSpecificationPath: string, +): Promise { + const workspacePackageNames = Object.values(project.workspacePackages).map( + (pkg) => pkg.manifest.name, + ); + const releaseSpecificationContents = await readFile(releaseSpecificationPath); + const indexOfFirstUsableLine = releaseSpecificationContents + .split('\n') + .findIndex((line) => !/^#|[ ]+/u.test(line)); + + let unvalidatedReleaseSpecification: { + packages: Record; + }; + + const afterwordForAllErrorMessages = [ + "The release spec file has been retained for you to edit again and make the necessary fixes. Once you've done this, re-run this tool.", + releaseSpecificationPath, + ].join('\n\n'); + + try { + unvalidatedReleaseSpecification = YAML.parse(releaseSpecificationContents); + } catch (error) { + throw wrapError( + [ + 'Your release spec does not appear to be valid YAML.', + afterwordForAllErrorMessages, + ].join('\n\n'), + error, + ); + } + + if ( + !isObject(unvalidatedReleaseSpecification) || + unvalidatedReleaseSpecification.packages === undefined + ) { + const message = [ + `Your release spec could not be processed because it needs to be an object with a \`packages\` property. The value of \`packages\` must itself be an object, where each key is a workspace package in the project and each value is a version specifier ("major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1.2.3").`, + `Here is the parsed version of the file you provided:`, + JSON.stringify(unvalidatedReleaseSpecification, null, 2), + afterwordForAllErrorMessages, + ].join('\n\n'); + throw new Error(message); + } + + const errors: { message: string | string[]; lineNumber: number }[] = []; + Object.keys(unvalidatedReleaseSpecification.packages).forEach( + (packageName, index) => { + const versionSpecifier = + unvalidatedReleaseSpecification.packages[packageName]; + const lineNumber = indexOfFirstUsableLine + index + 2; + + if (!workspacePackageNames.includes(packageName)) { + errors.push({ + message: `${JSON.stringify( + packageName, + )} is not a package in the project`, + lineNumber, + }); + } + + if ( + versionSpecifier !== null && + !hasProperty(IncrementableVersionParts, versionSpecifier) && + !isValidSemver(versionSpecifier) + ) { + errors.push({ + message: [ + `${JSON.stringify( + versionSpecifier, + )} is not a valid version specifier for package "${packageName}"`, + `(must be "major", "minor", or "patch"; or a version string with major, minor, and patch parts, such as "1.2.3")`, + ], + lineNumber, + }); + } + }, + ); + + if (errors.length > 0) { + const message = [ + 'Your release spec could not be processed due to the following issues:', + errors + .flatMap((error) => { + const itemPrefix = '- '; + const lineNumberPrefix = `Line ${error.lineNumber}: `; + + if (Array.isArray(error.message)) { + return [ + `${itemPrefix}${lineNumberPrefix}${error.message[0]}`, + ...error.message.slice(1).map((line) => { + const indentedLineLength = + itemPrefix.length + lineNumberPrefix.length + line.length; + return line.padStart(indentedLineLength, ' '); + }), + ]; + } + + return `${itemPrefix}${lineNumberPrefix}${error.message}`; + }) + .join('\n'), + afterwordForAllErrorMessages, + ].join('\n\n'); + throw new Error(message); + } + + const packages = Object.keys(unvalidatedReleaseSpecification.packages).reduce( + (obj, packageName) => { + const versionSpecifier = + unvalidatedReleaseSpecification.packages[packageName]; + + if (versionSpecifier) { + if ( + Object.values(IncrementableVersionParts).includes( + versionSpecifier as any, + ) + ) { + return { + ...obj, + // Typecast: We know what this is as we've checked it above. + [packageName]: versionSpecifier as IncrementableVersionParts, + }; + } + + return { + ...obj, + // Typecast: We know that this will safely parse. + [packageName]: semver.parse(versionSpecifier) as SemVer, + }; + } + + return obj; + }, + {} as ReleaseSpecification['packages'], + ); + + return { packages }; +} diff --git a/src/repo.test.ts b/src/repo.test.ts new file mode 100644 index 0000000..63b8f12 --- /dev/null +++ b/src/repo.test.ts @@ -0,0 +1,87 @@ +import { when } from 'jest-when'; +import { getStdoutFromGitCommandWithin, getRepositoryHttpsUrl } from './repo'; +import * as miscUtils from './misc-utils'; + +jest.mock('./misc-utils'); + +describe('git-utils', () => { + describe('getStdoutFromGitCommandWithin', () => { + it('calls getStdoutFromCommand with "git" as the command, passing the given args and using the given directory as the working directory', async () => { + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['foo', 'bar'], { cwd: '/path/to/repo' }) + .mockResolvedValue('the output'); + + const output = await getStdoutFromGitCommandWithin('/path/to/repo', [ + 'foo', + 'bar', + ]); + + expect(output).toStrictEqual('the output'); + }); + }); + + describe('getRepositoryHttpsUrl', () => { + it('returns the URL of the "origin" remote of the given repo if it looks like a HTTPS public GitHub repo URL', async () => { + const repositoryDirectoryPath = '/path/to/project'; + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['config', '--get', 'remote.origin.url'], { + cwd: repositoryDirectoryPath, + }) + .mockResolvedValue('/~https://github.com/foo'); + + expect( + await getRepositoryHttpsUrl(repositoryDirectoryPath), + ).toStrictEqual('/~https://github.com/foo'); + }); + + it('converts an SSH GitHub repo URL into an HTTPS URL', async () => { + const repositoryDirectoryPath = '/path/to/project'; + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['config', '--get', 'remote.origin.url'], { + cwd: repositoryDirectoryPath, + }) + .mockResolvedValue('git@github.com:Foo/Bar.git'); + + expect( + await getRepositoryHttpsUrl(repositoryDirectoryPath), + ).toStrictEqual('/~https://github.com/Foo/Bar'); + }); + + it('throws if the URL of the "origin" remote is in an invalid format', async () => { + const repositoryDirectoryPath = '/path/to/project'; + when(jest.spyOn(miscUtils, 'getStdoutFromCommand')) + .calledWith('git', ['config', '--get', 'remote.origin.url'], { + cwd: repositoryDirectoryPath, + }) + .mockResolvedValueOnce('foo') + .mockResolvedValueOnce('http://github.com/Foo/Bar') + .mockResolvedValueOnce('https://gitbar.foo/Foo/Bar') + .mockResolvedValueOnce('git@gitbar.foo:Foo/Bar.git') + .mockResolvedValueOnce('git@github.com:Foo/Bar.foo'); + + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow('Unrecognized URL for git remote "origin": foo'); + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( + 'Unrecognized URL for git remote "origin": http://github.com/Foo/Bar', + ); + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( + 'Unrecognized URL for git remote "origin": https://gitbar.foo/Foo/Bar', + ); + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( + 'Unrecognized URL for git remote "origin": git@gitbar.foo:Foo/Bar.git', + ); + await expect( + getRepositoryHttpsUrl(repositoryDirectoryPath), + ).rejects.toThrow( + 'Unrecognized URL for git remote "origin": git@github.com:Foo/Bar.foo', + ); + }); + }); +}); diff --git a/src/repo.ts b/src/repo.ts new file mode 100644 index 0000000..188f6f2 --- /dev/null +++ b/src/repo.ts @@ -0,0 +1,80 @@ +import { getStdoutFromCommand } from './misc-utils'; + +/** + * Runs a command within the given directory, obtaining the immediate output. + * + * @param directoryPath - The path to the directory. + * @param command - The command to execute. + * @param args - The positional arguments to the command. + * @returns The standard output of the command. + * @throws An execa error object if the command fails in some way. + */ +async function getStdoutFromCommandWithin( + directoryPath: string, + command: string, + args?: readonly string[] | undefined, +): Promise { + return await getStdoutFromCommand(command, args, { cwd: directoryPath }); +} + +/** + * Runs a Git command within the given repository, obtaining the immediate + * output. + * + * @param repositoryDirectoryPath - The directory of the repository. + * @param args - The arguments to the command. + * @returns The standard output of the command. + * @throws An execa error object if the command fails in some way. + */ +export async function getStdoutFromGitCommandWithin( + repositoryDirectoryPath: string, + args: readonly string[], +) { + return await getStdoutFromCommandWithin(repositoryDirectoryPath, 'git', args); +} + +/** + * Gets the HTTPS URL of the primary remote with which the given repository has + * been configured. Assumes that the git config `remote.origin.url` string + * matches one of: + * + * - /~https://github.com/OrganizationName/RepositoryName + * - git@github.com:OrganizationName/RepositoryName.git + * + * If the URL of the "origin" remote matches neither pattern, an error is + * thrown. + * + * @param repositoryDirectoryPath - The path to the project directory. + * @returns The HTTPS URL of the repository, e.g. + * `/~https://github.com/OrganizationName/RepositoryName`. + */ +export async function getRepositoryHttpsUrl( + repositoryDirectoryPath: string, +): Promise { + const httpsPrefix = 'https://github.com'; + const sshPrefixRegex = /^git@github\.com:/u; + const sshPostfixRegex = /\.git$/u; + const gitConfigUrl = await getStdoutFromCommandWithin( + repositoryDirectoryPath, + 'git', + ['config', '--get', 'remote.origin.url'], + ); + + if (gitConfigUrl.startsWith(httpsPrefix)) { + return gitConfigUrl; + } + + // Extracts "OrganizationName/RepositoryName" from + // "git@github.com:OrganizationName/RepositoryName.git" and returns the + // corresponding HTTPS URL. + if ( + gitConfigUrl.match(sshPrefixRegex) && + gitConfigUrl.match(sshPostfixRegex) + ) { + return `${httpsPrefix}/${gitConfigUrl + .replace(sshPrefixRegex, '') + .replace(sshPostfixRegex, '')}`; + } + + throw new Error(`Unrecognized URL for git remote "origin": ${gitConfigUrl}`); +} diff --git a/src/semver.ts b/src/semver.ts new file mode 100644 index 0000000..468162a --- /dev/null +++ b/src/semver.ts @@ -0,0 +1,2 @@ +export { default as semver, SemVer } from 'semver'; +export { isValidSemver } from '@metamask/action-utils'; diff --git a/src/workflow-operations.test.ts b/src/workflow-operations.test.ts new file mode 100644 index 0000000..635d807 --- /dev/null +++ b/src/workflow-operations.test.ts @@ -0,0 +1,40 @@ +import { buildMockProject } from '../tests/unit/helpers'; +import { captureChangesInReleaseBranch } from './workflow-operations'; +import * as repoModule from './repo'; + +jest.mock('./repo'); + +describe('workflow-operations', () => { + describe('captureChangesInReleaseBranch', () => { + it('checks out a new branch named after the name of the release, stages all changes, then commits them to the branch', async () => { + const project = buildMockProject({ + directoryPath: '/path/to/project', + }); + const releasePlan = { + releaseName: 'release-name', + packages: [], + }; + const getStdoutFromGitCommandWithinSpy = jest + .spyOn(repoModule, 'getStdoutFromGitCommandWithin') + .mockResolvedValue('the output'); + + await captureChangesInReleaseBranch(project, releasePlan); + + expect(getStdoutFromGitCommandWithinSpy).toHaveBeenNthCalledWith( + 1, + '/path/to/project', + ['checkout', '-b', 'release/release-name'], + ); + expect(getStdoutFromGitCommandWithinSpy).toHaveBeenNthCalledWith( + 2, + '/path/to/project', + ['add', '-A'], + ); + expect(getStdoutFromGitCommandWithinSpy).toHaveBeenNthCalledWith( + 3, + '/path/to/project', + ['commit', '-m', 'Release release-name'], + ); + }); + }); +}); diff --git a/src/workflow-operations.ts b/src/workflow-operations.ts new file mode 100644 index 0000000..0120a2f --- /dev/null +++ b/src/workflow-operations.ts @@ -0,0 +1,70 @@ +import { Package } from './package'; +import { Project } from './project'; +import { getStdoutFromGitCommandWithin } from './repo'; + +/** + * Instructions for how to update the project in order to prepare it for a new + * release. + * + * @property releaseName - The name of the new release. For a polyrepo or a + * monorepo with fixed versions, this will be a version string with the shape + * `..`; for a monorepo with independent versions, this + * will be a version string with the shape `..-`. + * @property packages - Information about all of the packages in the project. + * For a polyrepo, this consists of the self-same package; for a monorepo it + * consists of the root package and any workspace packages. + */ +export interface ReleasePlan { + releaseName: string; + packages: PackageReleasePlan[]; +} + +/** + * Instructions for how to update a package within a project in order to prepare + * it for a new release. + * + * @property package - Information about the package. + * @property newVersion - The new version to which the package should be + * updated. + * @property shouldUpdateChangelog - Whether or not the changelog for the + * package should get updated. For a polyrepo, this will always be true; for a + * monorepo, this will be true only for workspace packages (the root package + * doesn't have a changelog, since it is a virtual package). + */ +export interface PackageReleasePlan { + package: Package; + newVersion: string; + shouldUpdateChangelog: boolean; +} + +/** + * This function does three things: + * + * 1. Stages all of the changes which have been made to the repo thus far and + * creates a new Git commit which carries the name of the new release. + * 2. Creates a new branch pointed to that commit (which also carries the name + * of the new release). + * 3. Switches to that branch. + * + * @param project - Information about the whole project (e.g., names of packages + * and where they can found). + * @param releasePlan - Compiled instructions on how exactly to update the + * project in order to prepare a new release. + */ +export async function captureChangesInReleaseBranch( + project: Project, + releasePlan: ReleasePlan, +) { + await getStdoutFromGitCommandWithin(project.directoryPath, [ + 'checkout', + '-b', + `release/${releasePlan.releaseName}`, + ]); + await getStdoutFromGitCommandWithin(project.directoryPath, ['add', '-A']); + await getStdoutFromGitCommandWithin(project.directoryPath, [ + 'commit', + '-m', + `Release ${releasePlan.releaseName}`, + ]); +} diff --git a/tests/setupAfterEnv.ts b/tests/setupAfterEnv.ts new file mode 100644 index 0000000..42ea374 --- /dev/null +++ b/tests/setupAfterEnv.ts @@ -0,0 +1,80 @@ +declare global { + // Using `namespace` here is okay because this is how the Jest types are + // defined. + /* eslint-disable-next-line @typescript-eslint/no-namespace */ + namespace jest { + interface Matchers { + toResolve(): Promise; + } + } +} + +// Export something so that TypeScript thinks that we are performing type +// augmentation +export {}; + +const UNRESOLVED = Symbol('timedOut'); +// Store this in case it gets stubbed later +const originalSetTimeout = global.setTimeout; +const TIME_TO_WAIT_UNTIL_UNRESOLVED = 100; + +/** + * Produces a sort of dummy promise which can be used in conjunction with a + * "real" promise to determine whether the "real" promise was ever resolved. If + * the promise that is produced by this function resolves first, then the other + * one must be unresolved. + * + * @param duration - How long to wait before resolving the promise returned by + * this function. + * @returns A promise that resolves to a symbol. + */ +const treatUnresolvedAfter = (duration: number): Promise => { + return new Promise((resolve) => { + originalSetTimeout(resolve, duration, UNRESOLVED); + }); +}; + +expect.extend({ + /** + * Tests that the given promise is resolved within a certain amount of time + * (which defaults to the time that Jest tests wait before timing out as + * configured in the Jest configuration file). + * + * Inspired by . + * + * @param promise - The promise to test. + * @returns The result of the matcher. + */ + async toResolve(promise: Promise) { + if (this.isNot) { + throw new Error('Using `.not.toResolve(...)` is not supported.'); + } + + let resolutionValue: any; + let rejectionValue: any; + + try { + resolutionValue = await Promise.race([ + promise, + treatUnresolvedAfter(TIME_TO_WAIT_UNTIL_UNRESOLVED), + ]); + } catch (e) { + rejectionValue = e; + } + + return rejectionValue !== undefined || resolutionValue === UNRESOLVED + ? { + message: () => { + return `Expected promise to resolve after ${TIME_TO_WAIT_UNTIL_UNRESOLVED}ms, but it ${ + rejectionValue === undefined ? 'did not' : 'was rejected' + }.`; + }, + pass: false, + } + : { + message: () => + `This message should never get produced because .isNot is disallowed.`, + pass: true, + }; + }, +}); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts new file mode 100644 index 0000000..4748aa1 --- /dev/null +++ b/tests/unit/helpers.ts @@ -0,0 +1,183 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import util from 'util'; +import rimraf from 'rimraf'; +import { SemVer } from 'semver'; +import { nanoid } from 'nanoid'; +import type { Package } from '../../src/package'; +import { + PackageManifestFieldNames, + PackageManifestDependenciesFieldNames, +} from '../../src/package-manifest'; +import type { ValidatedPackageManifest } from '../../src/package-manifest'; +import type { Project } from '../../src/project'; + +/** + * Returns a version of the given record type where optionality is added to + * the designated keys. + */ +type Unrequire = Omit & { + [P in K]+?: T[P]; +}; + +/** + * Information about the sandbox provided to tests that need access to the + * filesystem. + */ +interface Sandbox { + directoryPath: string; +} + +/** + * A promisified version of `rimraf`. + */ +const promisifiedRimraf = util.promisify(rimraf); + +/** + * The temporary directory that acts as a filesystem sandbox for tests. + */ +const TEMP_DIRECTORY_PATH = path.join( + os.tmpdir(), + 'create-release-branch-tests', +); + +/** + * Each test gets its own randomly generated directory in a temporary directory + * where it can perform filesystem operations. There is a miniscule chance + * that more than one test will receive the same name for its directory. If this + * happens, then all bets are off, and we should stop running tests, because + * the state that we expect to be isolated to a single test has now bled into + * another test. + * + * @param entryPath - The path to the directory. + * @throws If the directory already exists (or a file exists in its place). + */ +async function ensureFileEntryDoesNotExist(entryPath: string): Promise { + try { + await fs.promises.access(entryPath); + throw new Error(`${entryPath} already exists, cannot continue`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } +} + +/** + * Creates a temporary directory to hold files that a test could write to, runs + * the given function, then ensures that the directory is removed afterward. + * + * @param fn - The function to call. + * @throws If the temporary directory already exists for some reason. This would + * indicate a bug in how the names of the directory is determined. + */ +export async function withSandbox(fn: (sandbox: Sandbox) => any) { + const directoryPath = path.join(TEMP_DIRECTORY_PATH, nanoid()); + await ensureFileEntryDoesNotExist(directoryPath); + await fs.promises.mkdir(directoryPath, { recursive: true }); + + try { + await fn({ directoryPath }); + } finally { + await promisifiedRimraf(directoryPath); + } +} + +/** + * Builds a project object for use in tests. All properties have default + * values, so you can specify only the properties you care about. + * + * @param overrides - The properties that will go into the object. + * @returns The mock Project. + */ +export function buildMockProject(overrides: Partial = {}): Project { + return { + directoryPath: '/path/to/project', + repositoryUrl: 'https://repo.url', + rootPackage: buildMockPackage('root'), + workspacePackages: {}, + isMonorepo: false, + ...overrides, + }; +} + +type MockPackageOverrides = Omit< + Unrequire, + 'manifest' +> & { + manifest?: Omit< + Partial, + PackageManifestFieldNames.Name | PackageManifestFieldNames.Version + >; +}; + +/** + * Builds a package object for use in tests. All properties have default + * values, so you can specify only the properties you care about. + * + * @param name - The name of the package. + * @param args - Either the version of the package and the properties that will + * go into the object, or just the properties. + * @returns The mock Package object. + */ +export function buildMockPackage( + name: string, + ...args: [string | SemVer, MockPackageOverrides] | [MockPackageOverrides] | [] +): Package { + let version, overrides; + + if (args.length === 0) { + version = '1.0.0'; + overrides = {}; + } else if (args.length === 1) { + version = '1.0.0'; + overrides = args[0]; + } else { + version = args[0]; + overrides = args[1]; + } + + const { + manifest = {}, + directoryPath = `/path/to/packages/${name}`, + manifestPath = path.join(directoryPath, 'package.json'), + changelogPath = path.join(directoryPath, 'CHANGELOG.md'), + } = overrides; + + return { + directoryPath, + manifest: buildMockManifest({ + ...manifest, + [PackageManifestFieldNames.Name]: name, + [PackageManifestFieldNames.Version]: + version instanceof SemVer ? version : new SemVer(version), + }), + manifestPath, + changelogPath, + }; +} + +/** + * Builds a manifest object for use in tests. All properties have default + * values, so you can specify only the properties you care about. + * + * @param overrides - The properties to override in the manifest. + * @returns The mock ValidatedPackageManifest. + */ +export function buildMockManifest( + overrides: Partial = {}, +): ValidatedPackageManifest { + return { + [PackageManifestFieldNames.Name]: 'foo', + [PackageManifestFieldNames.Version]: new SemVer('1.2.3'), + [PackageManifestFieldNames.Private]: false, + [PackageManifestFieldNames.Workspaces]: [], + [PackageManifestDependenciesFieldNames.Bundled]: {}, + [PackageManifestDependenciesFieldNames.Production]: {}, + [PackageManifestDependenciesFieldNames.Development]: {}, + [PackageManifestDependenciesFieldNames.Optional]: {}, + [PackageManifestDependenciesFieldNames.Peer]: {}, + ...overrides, + }; +} diff --git a/yarn.lock b/yarn.lock index c1e5680..c25fcd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -835,6 +835,17 @@ __metadata: languageName: node linkType: hard +"@metamask/action-utils@npm:^0.0.2": + version: 0.0.2 + resolution: "@metamask/action-utils@npm:0.0.2" + dependencies: + "@types/semver": ^7.3.6 + glob: ^7.1.7 + semver: ^7.3.5 + checksum: 4d3552d77a329791e2b1da0ca5023e9e04c1920c61f06ef070e8b4f4f072dd1f632124003964d47cdebf314a621a12d7209fbdd6871db37cfa1330a6ed679a11 + languageName: node + linkType: hard + "@metamask/auto-changelog@npm:^2.3.0": version: 2.6.1 resolution: "@metamask/auto-changelog@npm:2.6.1" @@ -854,15 +865,23 @@ __metadata: resolution: "@metamask/create-release-branch@workspace:." dependencies: "@lavamoat/allow-scripts": ^2.0.3 + "@metamask/action-utils": ^0.0.2 "@metamask/auto-changelog": ^2.3.0 "@metamask/eslint-config": ^9.0.0 "@metamask/eslint-config-jest": ^9.0.0 "@metamask/eslint-config-nodejs": ^9.0.0 "@metamask/eslint-config-typescript": ^9.0.1 + "@metamask/utils": ^2.0.0 + "@types/debug": ^4.1.7 "@types/jest": ^28.1.4 + "@types/jest-when": ^3.5.2 "@types/node": ^17.0.23 + "@types/rimraf": ^3.0.2 + "@types/which": ^2.0.1 + "@types/yargs": ^17.0.10 "@typescript-eslint/eslint-plugin": ^4.21.0 "@typescript-eslint/parser": ^4.21.0 + debug: ^4.3.4 eslint: ^7.23.0 eslint-config-prettier: ^8.1.0 eslint-plugin-import: ^2.22.1 @@ -870,14 +889,26 @@ __metadata: eslint-plugin-jsdoc: ^36.1.0 eslint-plugin-node: ^11.1.0 eslint-plugin-prettier: ^3.3.1 + execa: ^5.0.0 + glob: ^8.0.3 jest: ^28.0.0 jest-it-up: ^2.0.2 + jest-when: ^3.5.1 + nanoid: ^3.3.4 + pony-cause: ^2.1.0 prettier: ^2.2.1 prettier-plugin-packagejson: ^2.2.17 rimraf: ^3.0.2 + semver: ^7.3.7 + stdio-mock: ^1.2.0 ts-jest: ^28.0.0 ts-node: ^10.7.0 typescript: ^4.2.4 + which: ^2.0.2 + yaml: ^2.1.1 + yargs: ^17.5.1 + bin: + create-release-branch: dist/cli.js languageName: unknown linkType: soft @@ -930,6 +961,15 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^2.0.0": + version: 2.0.0 + resolution: "@metamask/utils@npm:2.0.0" + dependencies: + fast-deep-equal: ^3.1.3 + checksum: 517afc6724e58aee889b9962fcedc0345cb264ed8232756cd16e2d47e22b5501af276986a3d84a9ab903075d20802bf38ff4f6a70c58a158666f18cb69ff458d + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1106,7 +1146,16 @@ __metadata: languageName: node linkType: hard -"@types/glob@npm:^7.1.1": +"@types/debug@npm:^4.1.7": + version: 4.1.7 + resolution: "@types/debug@npm:4.1.7" + dependencies: + "@types/ms": "*" + checksum: 0a7b89d8ed72526858f0b61c6fd81f477853e8c4415bb97f48b1b5545248d2ae389931680b94b393b993a7cfe893537a200647d93defe6d87159b96812305adc + languageName: node + linkType: hard + +"@types/glob@npm:*, @types/glob@npm:^7.1.1": version: 7.2.0 resolution: "@types/glob@npm:7.2.0" dependencies: @@ -1150,7 +1199,16 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^28.1.4": +"@types/jest-when@npm:^3.5.2": + version: 3.5.2 + resolution: "@types/jest-when@npm:3.5.2" + dependencies: + "@types/jest": "*" + checksum: 106230dd71ee266bbd7620ab339a7305054e56ba7638f0eff9f222e67a959df0ad68a7f0108156c50f7005881bae59cd5c38b3760c101d060cdb3dac9cd77ee2 + languageName: node + linkType: hard + +"@types/jest@npm:*, @types/jest@npm:^28.1.4": version: 28.1.4 resolution: "@types/jest@npm:28.1.4" dependencies: @@ -1181,6 +1239,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 0.7.31 + resolution: "@types/ms@npm:0.7.31" + checksum: daadd354aedde024cce6f5aa873fefe7b71b22cd0e28632a69e8b677aeb48ae8caa1c60e5919bb781df040d116b01cb4316335167a3fc0ef6a63fa3614c0f6da + languageName: node + linkType: hard + "@types/node@npm:*": version: 18.0.3 resolution: "@types/node@npm:18.0.3" @@ -1202,6 +1267,23 @@ __metadata: languageName: node linkType: hard +"@types/rimraf@npm:^3.0.2": + version: 3.0.2 + resolution: "@types/rimraf@npm:3.0.2" + dependencies: + "@types/glob": "*" + "@types/node": "*" + checksum: b47fa302f46434cba704d20465861ad250df79467d3d289f9d6490d3aeeb41e8cb32dd80bd1a8fd833d1e185ac719fbf9be12e05ad9ce9be094d8ee8f1405347 + languageName: node + linkType: hard + +"@types/semver@npm:^7.3.6": + version: 7.3.10 + resolution: "@types/semver@npm:7.3.10" + checksum: 7047c2822b1759b2b950f39cfcf261f2b9dca47b4b55bdebba0905a8553631f1531eb0f59264ffe4834d1198c8331c8e0010a4cd742f4e0b60abbf399d134364 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -1209,6 +1291,13 @@ __metadata: languageName: node linkType: hard +"@types/which@npm:^2.0.1": + version: 2.0.1 + resolution: "@types/which@npm:2.0.1" + checksum: 14e963f2ffaa79caaa13044e977456085a1024cb519478f0f5f9dc8a4f33a0ac47f0a255ccbd008efde063c75b98e846b9258b5999bac6f595e06d5518920cb7 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -1216,7 +1305,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.8": +"@types/yargs@npm:^17.0.10, @types/yargs@npm:^17.0.8": version: 17.0.10 resolution: "@types/yargs@npm:17.0.10" dependencies: @@ -2071,7 +2160,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3": +"debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -3014,7 +3103,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.3, glob@npm:^7.1.4": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -3028,7 +3117,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^8.0.1": +"glob@npm:^8.0.1, glob@npm:^8.0.3": version: 8.0.3 resolution: "glob@npm:8.0.3" dependencies: @@ -4018,6 +4107,15 @@ __metadata: languageName: node linkType: hard +"jest-when@npm:^3.5.1": + version: 3.5.1 + resolution: "jest-when@npm:3.5.1" + peerDependencies: + jest: ">= 25" + checksum: 1efb9f497f7c846fe8b0f4125d5f449c4a4d78d5d0afa910d134b301ae4c119ea52c9465db38d2146269d42808afe8f3a4328d1d656878a9a69458ee653f6499 + languageName: node + linkType: hard + "jest-worker@npm:^28.1.1": version: 28.1.1 resolution: "jest-worker@npm:28.1.1" @@ -4493,6 +4591,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.4": + version: 3.3.4 + resolution: "nanoid@npm:3.3.4" + bin: + nanoid: bin/nanoid.cjs + checksum: 2fddd6dee994b7676f008d3ffa4ab16035a754f4bb586c61df5a22cf8c8c94017aadd360368f47d653829e0569a92b129979152ff97af23a558331e47e37cd9c + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -4868,6 +4975,13 @@ __metadata: languageName: node linkType: hard +"pony-cause@npm:^2.1.0": + version: 2.1.0 + resolution: "pony-cause@npm:2.1.0" + checksum: ae1df5d97da0cfeac3d5a16abb66f7e5dffe675f8fc0811f143a46b0a3409803def538b862e4dd9825635e8268157f532b6c8e5bcdf8f99e20d482bfba93c042 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -5213,7 +5327,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5": +"semver@npm:7.x, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.7": version: 7.3.7 resolution: "semver@npm:7.3.7" dependencies: @@ -5437,6 +5551,13 @@ __metadata: languageName: node linkType: hard +"stdio-mock@npm:^1.2.0": + version: 1.2.0 + resolution: "stdio-mock@npm:1.2.0" + checksum: 5c8739e11fc5a18cd5ef0b2e8d900cd1cd4e851f69915d3a829ebdaefd5292ece17f29981516636f9b9acb3026d8c802de6f138280a293cc484160a6c67e65ef + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" @@ -6054,6 +6175,13 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.1.1": + version: 2.1.1 + resolution: "yaml@npm:2.1.1" + checksum: f48bb209918aa57cfaf78ef6448d1a1f8187f45c746f933268b7023dc59e5456004611879126c9bb5ea55b0a2b1c2b392dfde436931ece0c703a3d754562bb96 + languageName: node + linkType: hard + "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -6083,7 +6211,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.0.1, yargs@npm:^17.3.1": +"yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.5.1": version: 17.5.1 resolution: "yargs@npm:17.5.1" dependencies: