diff --git a/package-lock.json b/package-lock.json index 8d57798e..69390fb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "4.0.1", "license": "MIT", "dependencies": { - "@wixc3/resolve-directory-context": "^1.0.3", + "@wixc3/resolve-directory-context": "^1.0.4", + "cli-cursor": "^4.0.0", "colorette": "^2.0.16", "commander": "^8.3.0", "p-queue": "^7.1.0", @@ -344,12 +345,12 @@ "dev": true }, "node_modules/@wixc3/resolve-directory-context": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@wixc3/resolve-directory-context/-/resolve-directory-context-1.0.3.tgz", - "integrity": "sha512-IcYEmdJMp63GEVD/HFEVmQjDhT313zewl94b5Xowbt/47jF3SLKQG7upp/JWHsJ2LyTNL9hIRivHjonYg2eURw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@wixc3/resolve-directory-context/-/resolve-directory-context-1.0.4.tgz", + "integrity": "sha512-jZVnCWX9u+NX7DT2iVTQqNUBdImHSJ9xHxtci5R5KEt/bGVODEaYnWUXYDyQZEJl/VQD8uYAGPkAbiKDOqMIDQ==", "dependencies": { "glob": "^7.2.0", - "type-fest": "^2.8.0" + "type-fest": "^2.9.0" }, "engines": { "node": ">=12" @@ -599,6 +600,20 @@ "node": ">= 6" } }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -1518,6 +1533,14 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -1677,6 +1700,20 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -1924,6 +1961,21 @@ "node": ">=4" } }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -2036,6 +2088,11 @@ "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.6.tgz", + "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -2516,12 +2573,12 @@ "dev": true }, "@wixc3/resolve-directory-context": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@wixc3/resolve-directory-context/-/resolve-directory-context-1.0.3.tgz", - "integrity": "sha512-IcYEmdJMp63GEVD/HFEVmQjDhT313zewl94b5Xowbt/47jF3SLKQG7upp/JWHsJ2LyTNL9hIRivHjonYg2eURw==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@wixc3/resolve-directory-context/-/resolve-directory-context-1.0.4.tgz", + "integrity": "sha512-jZVnCWX9u+NX7DT2iVTQqNUBdImHSJ9xHxtci5R5KEt/bGVODEaYnWUXYDyQZEJl/VQD8uYAGPkAbiKDOqMIDQ==", "requires": { "glob": "^7.2.0", - "type-fest": "^2.8.0" + "type-fest": "^2.9.0" } }, "acorn": { @@ -2702,6 +2759,14 @@ } } }, + "cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "requires": { + "restore-cursor": "^4.0.0" + } + }, "cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -3385,6 +3450,11 @@ "picomatch": "^2.2.3" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -3505,6 +3575,14 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -3660,6 +3738,15 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -3722,6 +3809,11 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "signal-exit": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.6.tgz", + "integrity": "sha512-sDl4qMFpijcGw22U5w63KmD3cZJfBuFlVNbVMKje2keoKML7X2UzWbc4XrmEbDwg0NXJc3yv4/ox7b+JWb57kQ==" + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/package.json b/package.json index 4464d49b..c8ebfd58 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "prettify": "npx prettier . --write" }, "dependencies": { - "@wixc3/resolve-directory-context": "^1.0.3", + "@wixc3/resolve-directory-context": "^1.0.4", + "cli-cursor": "^4.0.0", "colorette": "^2.0.16", "commander": "^8.3.0", "p-queue": "^7.1.0", diff --git a/src/cli.ts b/src/cli.ts index f2296546..ad886b3f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,6 +32,23 @@ program }); }); +program + .command('version [target]') + .description('careful upgrade semver version of selected packages') + .option('--dry-run', 'no actual file system operations', false) + .option('--mode', 'major | minor | patch', undefined) // TODO: expend to all Modes + .option('--identifier', 'identifier for the release', '') + .action(async (targetPath: string, { dryRun, mode, identifier }) => { + const { version } = await import('./commands/version.js'); + + version({ + directoryPath: path.resolve(targetPath || ''), + identifier, + dryRun, + mode, + }); + }); + program .command('upgrade [target]') .description('upgrade dependencies and devDependencies of all packages') diff --git a/src/commands/version.ts b/src/commands/version.ts new file mode 100644 index 00000000..34e1bf21 --- /dev/null +++ b/src/commands/version.ts @@ -0,0 +1,43 @@ +import { resolveDirectoryContext, getRootPackage, childPackagesFromContext } from '@wixc3/resolve-directory-context'; +import { upgrade } from '../utils/workspace-upgrade.js'; +import { versionSelector } from '../utils/version-selector.js'; +import { type Modes, preProcessPackages } from '../utils/semver.js'; + +export interface VersionOptions { + directoryPath: string; + identifier: string; + dryRun: boolean; + mode: Modes; +} + +export function version({ directoryPath, dryRun, mode, identifier }: VersionOptions) { + const project = directoryPath; + + const releaseIdentifier = identifier; + const directoryContext = resolveDirectoryContext(project); + + const packages = childPackagesFromContext(directoryContext); + const rootPackage = getRootPackage(directoryContext); + const packagesJson = packages.map(({ packageJson }) => packageJson); + const ignoredPackages = new Set(); + const { possibleVersions } = preProcessPackages({ + packagesJson, + releaseIdentifier, + ignoredPackages, + }); + + versionSelector({ + packages, + possibleVersions, + project, + rootPackage, + mode, + onSelect(versionMap) { + upgrade(project, versionMap, dryRun); + process.exit(0); // TODO + }, + onCancel() { + process.exit(0); // TODO + }, + }); +} diff --git a/src/utils/git.ts b/src/utils/git.ts index 99c0a529..8171edbd 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -4,3 +4,21 @@ export function currentGitCommitHash(cwd = process.cwd()): string | undefined { const { stdout, status } = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', cwd }); return status === 0 ? stdout.trim() : undefined; } + +// TODO: use status === 0 pattern +export const getGitStatus = (cwd: string) => spawnSync('git status', { shell: true, encoding: 'utf-8', cwd }).stdout; + +export const gitCommit = (cwd: string, message: string) => + spawnSync(`git commit -am ${JSON.stringify(message)}`, { shell: true, encoding: 'utf-8', cwd }).stdout; + +export const gitPush = (cwd: string) => spawnSync(`git push --tags`, { shell: true, encoding: 'utf-8', cwd }).stdout; + +export const gitTag = (cwd: string, version: string, packageName?: string) => + spawnSync(gitTagCommand(version, packageName), { + shell: true, + encoding: 'utf-8', + cwd, + }).stdout; + +export const gitTagCommand = (version: string, packageName?: string) => + `git tag -a ${packageName ? `${packageName}@${version}` : `v${version}`}`; diff --git a/src/utils/npm.ts b/src/utils/npm.ts new file mode 100644 index 00000000..062df73f --- /dev/null +++ b/src/utils/npm.ts @@ -0,0 +1,5 @@ +import { spawnSync } from 'child_process'; + +// TODO: use status === 0 pattern +export const updateLockFile = (cwd: string) => + spawnSync('npm install --package-lock-only', { shell: true, encoding: 'utf-8', cwd }).stdout; diff --git a/src/utils/semver.ts b/src/utils/semver.ts new file mode 100644 index 00000000..7a1a7b15 --- /dev/null +++ b/src/utils/semver.ts @@ -0,0 +1,110 @@ +import type { PackageJson } from 'type-fest'; +import semver from 'semver'; + +export const modes = ['current', 'commutative', 'prerelease', 'patch', 'minor', 'major'] as const; + +export type Modes = typeof modes[number]; + +export type PossibleVersions = readonly [ + { version: string; value: 'current' }, + { version: string; value: 'commutative' }, + { version: string; value: 'prerelease' }, + { version: string; value: 'patch' }, + { version: string; value: 'minor' }, + { version: string; value: 'major' } +]; + +export const modesToIndex = { + current: 0, + commutative: 1, + prerelease: 2, + patch: 3, + minor: 4, + major: 5, +} as const; + +export function getPossibleReleasesOptions(current: string, commutative: string, identifier = ''): PossibleVersions { + const pre = identifier ? ('pre' as const) : ('' as const); + const patch = String(semver.inc(current, `${pre}patch`, identifier)); + const minor = String(semver.inc(current, `${pre}minor`, identifier)); + const major = String(semver.inc(current, `${pre}major`, identifier)); + + const prerelease = String(semver.inc(current, 'prerelease', identifier)); + + return [ + { version: current, value: 'current' }, + { version: commutative, value: 'commutative' }, + { version: prerelease, value: 'prerelease' }, + { version: patch, value: 'patch' }, + { version: minor, value: 'minor' }, + { version: major, value: 'major' }, + ]; +} + +export function preProcessPackages({ + packagesJson, + releaseIdentifier, + ignoredPackages, +}: { + packagesJson: readonly PackageJson[]; + releaseIdentifier: string; + ignoredPackages: Set; +}) { + const { commutative, minRelease } = commutativeVersion( + packagesJson.filter((_, i) => !ignoredPackages.has(i)), + releaseIdentifier + ); + const possibleVersions = packagesJson.map(({ version }, i) => + getPossibleReleasesOptions( + version!, //TODO: fixme??? (!) + ignoredPackages.has(i) ? '-' : commutative, + releaseIdentifier + ) + ); + + return { + possibleVersions, + commutative, + commutativeMinRelease: minRelease, + } as const; +} + +export function commutativeVersion(packages: { version?: string }[], releaseIdentifier: string) { + let diff: semver.ReleaseType = 'patch'; + let max = ''; + + for (const p of packages) { + if (!p.version) { + throw new Error('missing version'); + } + if (!max) { + max = p.version; + } + if (semver.gte(p.version, max)) { + const resDiff = semver.diff(p.version, max); + if (resDiff === null) { + continue; + } + diff = resDiff; + max = p.version; + } + } + + // TODO: help needed + if (diff === 'prerelease') { + throw new Error('prerelease are not supported'); + } + + // TODO: help needed??? + if (diff?.startsWith('pre')) { + diff = diff.slice(3) as 'major' | 'minor' | 'patch'; + } + + if (releaseIdentifier) { + diff = 'prerelease'; + } + + const commutative = semver.inc(max, diff, releaseIdentifier) || '-'; + + return { commutative, max, minRelease: diff }; +} diff --git a/src/utils/version-selector.ts b/src/utils/version-selector.ts new file mode 100644 index 00000000..fafc63ec --- /dev/null +++ b/src/utils/version-selector.ts @@ -0,0 +1,110 @@ +import readline from 'readline'; +import cursor from 'cli-cursor'; +import { cyan, green, underline } from 'colorette'; +import type { INpmPackage } from '@wixc3/resolve-directory-context'; +import { type Modes, type PossibleVersions, modesToIndex } from './semver.js'; + +export function versionSelector({ + project, + rootPackage, + packages, + possibleVersions, + mode = 'commutative', + onSelect, + onCancel, +}: { + project: string; + rootPackage: INpmPackage; + packages: INpmPackage[]; + possibleVersions: PossibleVersions[]; + mode?: Modes; + onSelect?: (versionMap: Record) => void; + onCancel?: () => void; +}) { + const possibilitiesSize = possibleVersions[0]!.length; + const paddingSize = getPaddingSize(packages); + const selected: number[] = packages.map(() => modesToIndex[mode]); + const state = { current: 0 }; + const writeLine = lineRenderer(); + + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.on('keypress', handleKey); + cursor.hide(process.stdout); + process.stdout.cursorTo(0, 0); + process.stdout.clearScreenDown(render); + + function handleKey(_: string, key: { ctrl: boolean; name: string }) { + if (key.name === 'up') { + state.current--; + if (state.current < 0) { + state.current = packages.length - 1; + } + } else if (key.name === 'down') { + state.current++; + if (state.current >= packages.length) { + state.current = 0; + } + } else if (key.name === 'left') { + selected[state.current]--; + if (selected[state.current]! < 0) { + selected[state.current] = possibilitiesSize - 1; + } + } else if (key.name === 'right') { + selected[state.current]++; + if (selected[state.current]! >= possibilitiesSize) { + selected[state.current] = 0; + } + } else if (key.name === 'return') { + const versionMap = packages.reduce>((acc, { displayName }, i) => { + acc[displayName] = possibleVersions[i]![selected[i]!]!.version; + return acc; + }, {}); + onSelect?.(versionMap); + } else if (key.ctrl && key.name === 'c') { + onCancel?.(); + } + render(); + } + + function render() { + process.stdout.cursorTo(0, 0); + writeLine(underline(`Release for ${rootPackage.displayName} in ${project}`), -1); + + packages.forEach(({ displayName }, i) => { + const versions = possibleVersions[i]!; + const name = padTo(`${displayName}@${versions[0].version}`, paddingSize); + const arrow = ` -> `; + const selectedVersion = versions[selected[i]!]!; + const version = `${selectedVersion.version} (${selectedVersion.value})`; + + writeLine(i === state.current ? cyan(name) + arrow + green(version) : name + arrow + version, i); + }); + } +} + +function lineRenderer() { + const rendered: string[] = []; + return function writeLine(line: string, index: number) { + if (rendered[index] !== line) { + process.stdout.clearLine(1); + } + if (index !== -1) { + rendered[index] = line; + } + process.stdout.write(`${line}\n`); + }; +} + +function getPaddingSize(packages: INpmPackage[]) { + const maxPackageNameLength = packages.reduce((max, { displayName }) => Math.max(max, displayName.length), 0); + const maxCurrentVersionLength = packages.reduce( + (max, { packageJson: { version = '0.0.0' } }) => Math.max(max, version.length), + 0 + ); + return maxPackageNameLength + maxCurrentVersionLength + /*@*/ 1; +} + +function padTo(str: string, length: number) { + return `${str}${' '.repeat(length - str.length)}`; +} diff --git a/src/utils/workspace-upgrade.ts b/src/utils/workspace-upgrade.ts new file mode 100644 index 00000000..f389aefa --- /dev/null +++ b/src/utils/workspace-upgrade.ts @@ -0,0 +1,175 @@ +/* eslint-disable no-console */ +import { join } from 'path'; +import { writeFileSync, readFileSync } from 'fs'; +import { satisfies, lte } from 'semver'; +import { resolveDirectoryContext, childPackagesFromContext } from '@wixc3/resolve-directory-context'; +import { updateLockFile } from './npm.js'; +import { gitTag, gitTagCommand, getGitStatus, gitCommit, gitPush } from './git.js'; + +const log = console.log.bind(console, '[Release]'); + +export function upgrade(projectPath: string, packageMap: Record, dryRun: boolean) { + if (Object.keys(packageMap).length === 0) { + throw new Error('No packages to release'); + } + log('Checking git status'); + if (!getGitStatus(projectPath).includes('nothing to commit')) { + throw new Error('git status is not clean'); + } + log('Git status is clean'); + + const directoryContext = resolveDirectoryContext(projectPath); + const packages = childPackagesFromContext(directoryContext); + const updateStatus = { + errors: [] as string[], + }; + log('Upgrading packages'); + for (const [packageName, version] of Object.entries(packageMap)) { + let found = false; + + for (const { packageJson, displayName } of packages) { + if (displayName === packageName) { + const currentVersion = packageJson.version || '0.0.0'; + if (lte(version, currentVersion)) { + updateStatus.errors.push(`${packageName}@${currentVersion} is already at ${version} or higher`); + } + log(`Upgrading ${packageName} ${currentVersion} -> ${version}`); + packageJson.version = version; + found = true; + } + if (packageJson.dependencies) { + const dependencies = packageJson.dependencies; + const dependency = dependencies[packageName]; + if (dependency) { + dependencies[packageName] = getModifier(dependency) + version; + } + } + if (packageJson.devDependencies) { + const devDependencies = packageJson.devDependencies; + const dependency = devDependencies[packageName]; + if (dependency) { + devDependencies[packageName] = getModifier(dependency) + version; + } + } + if (packageJson.optionalDependencies) { + const optionalDependencies = packageJson.optionalDependencies; + const dependency = optionalDependencies[packageName]; + if (dependency) { + optionalDependencies[packageName] = getModifier(dependency) + version; + } + } + if (packageJson.peerDependencies) { + const peerDependencies = packageJson.peerDependencies; + if (peerDependencies[packageName]) { + if (!satisfies(version, peerDependencies[packageName]!)) { + updateStatus.errors.push( + `Version ${version} for ${packageName} doesn't satisfy peerDependency of ${displayName}` + ); + } + } + } + } + + if (!found) { + updateStatus.errors.push(`Package ${packageName} not found`); + } + } + + if (updateStatus.errors.length) { + throw new Error(updateStatus.errors.join('\n')); + } + log('Done.'); + + log('Writing package.json'); + if (!dryRun) { + for (const { packageJson, packageJsonPath } of packages) { + writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); + } + } + log('Done.'); + + log('Updating npm lock file'); + manualPatchWorkspacePackages(projectPath, packageMap, dryRun); + if (!dryRun) { + if (!updateLockFile(projectPath)) { + throw new Error('Failed to update npm lock file'); + } + } + log('Done.'); + if (!dryRun) { + createGitTags(packageMap, projectPath, dryRun); + } + log('Done.'); + + log('Committing changes'); + if (!dryRun) { + gitCommit(projectPath, 'Release'); + } + log('Done.'); + + log('Pushing changes'); + if (!dryRun) { + gitPush(projectPath); + } + log('Done.'); +} + +function createGitTags(packageMap: Record, projectPath: string, dryRun: boolean) { + const versionToPackageMap = versionToPackage(packageMap); + if (versionToPackageMap.size === 1) { + // unified version + log('Creating git tag'); + for (const [version] of versionToPackageMap) { + log(dryRun ? gitTagCommand(version) : gitTag(projectPath, version)); + } + } else { + // non-unified version + log(`Creating git tags for ${versionToPackageMap.size} versions`); + // create tag for each package in the same version + for (const [version, packages] of versionToPackageMap) { + for (const packageName of packages) { + log(dryRun ? gitTagCommand(version, packageName) : gitTag(projectPath, version, packageName)); + } + } + } +} + +function versionToPackage(packageMap: Record) { + const groupedPackageMap = new Map(); + for (const [packageName, version] of Object.entries(packageMap)) { + const group = groupedPackageMap.get(version); + if (group) { + group.push(packageName); + } else { + groupedPackageMap.set(version, [packageName]); + } + } + return groupedPackageMap; +} + +function manualPatchWorkspacePackages(projectPath: string, packageMap: Record, dryRun: boolean) { + const lockFilePath = join(projectPath, 'package-lock.json'); + const packageLock = JSON.parse(readFileSync(lockFilePath, 'utf-8')) as { + packages: Record; + lockfileVersion: number; + }; + + if (packageLock.lockfileVersion !== 2) { + throw new Error('package-lock.json version is not 2'); + } + let count = 0; + for (const packageEntry of Object.values(packageLock.packages)) { + if (packageMap[packageEntry.name]) { + count++; + packageEntry.version = packageMap[packageEntry.name]!; + } + } + log(`Manual updating ${count} workspace packages`); + if (!dryRun) { + writeFileSync(lockFilePath, JSON.stringify(packageLock, null, 2) + '\n'); + } +} + +function getModifier(dependency: string) { + return dependency.startsWith('^') ? '^' : dependency.startsWith('~') ? '~' : ''; +}