1import chalk from 'chalk'; 2import inquirer from 'inquirer'; 3import pick from 'lodash/pick'; 4import npmPacklist from 'npm-packlist'; 5import semver from 'semver'; 6 7import * as Changelogs from '../Changelogs'; 8import * as Formatter from '../Formatter'; 9import { GitDirectory, GitFileLog, GitFileStatus } from '../Git'; 10import logger from '../Logger'; 11import { Package } from '../Packages'; 12import { 13 BACKUPABLE_OPTIONS_FIELDS, 14 NATIVE_DIRECTORIES, 15 RELEASE_TYPES_ASC_ORDER, 16} from './constants'; 17import { BackupableOptions, CommandOptions, PackageGitLogs, Parcel, ReleaseType } from './types'; 18 19const { green, cyan, magenta, gray, red } = chalk; 20 21/** 22 * Returns options that are capable of being backed up. 23 * We will need just a few options to determine whether the backup is valid 24 * and we can't pass them all because `options` is in fact commander's `Command` instance. 25 */ 26export function pickBackupableOptions(options: CommandOptions): BackupableOptions { 27 return pick(options, BACKUPABLE_OPTIONS_FIELDS); 28} 29 30/** 31 * Whether tasks backup can be used to retry previous command invocation. 32 */ 33export async function shouldUseBackupAsync(options: CommandOptions): Promise<boolean> { 34 if (process.env.CI) { 35 return false; 36 } 37 if (options.retry) { 38 return true; 39 } 40 const { restore } = await inquirer.prompt([ 41 { 42 type: 'confirm', 43 name: 'restore', 44 prefix: '❔', 45 message: cyan('Found valid backup file. Would you like to use it?'), 46 }, 47 ]); 48 logger.log(); 49 return restore; 50} 51 52/** 53 * Prints gathered crucial informations about the package. 54 */ 55export function printPackageParcel(parcel: Parcel): void { 56 const { pkg, pkgView, state, logs, changelogChanges, dependencies, dependents } = parcel; 57 const { releaseType, releaseVersion } = state; 58 const gitHead = pkgView?.gitHead; 59 60 logger.log( 61 '\n', 62 `${green.bold(pkg.packageName)},`, 63 `current version ${cyan.bold(pkg.packageVersion)},`, 64 pkgView ? `published from ${Formatter.formatCommitHash(gitHead)}` : 'not published yet' 65 ); 66 67 if (dependents.size) { 68 logger.log( 69 ' ', 70 magenta('Dependency of:'), 71 [...dependents].map((dependent) => green(dependent.pkg.packageName)).join(', ') 72 ); 73 } 74 75 if (!pkgView) { 76 logger.log( 77 ' ', 78 magenta(`Version ${cyan.bold(pkg.packageVersion)} hasn't been published yet.`) 79 ); 80 } else if (!logs) { 81 logger.warn(" We couldn't determine new commits for this package."); 82 83 if (gitHead) { 84 // There are no logs and `gitHead` is there, so probably it's unreachable. 85 logger.warn(' Git head of its current version is not reachable from this branch.'); 86 } else { 87 logger.warn(" It doesn't seem to be published by this script yet."); 88 } 89 } 90 91 if (dependencies.size) { 92 logger.log(' ', magenta('Package depends on:')); 93 94 dependencies.forEach((dependency) => { 95 const fromVersion = dependency.pkg.packageVersion; 96 const toVersion = dependency.state.releaseVersion; 97 98 logger.log( 99 ' ', 100 green(dependency.pkg.packageName), 101 gray(`(upgrades from ${cyan(fromVersion)}${toVersion ? ` to ${cyan(toVersion)}` : ''})`) 102 ); 103 }); 104 } 105 if (logs && logs.commits.length > 0) { 106 logger.log(' ', magenta('New commits:')); 107 108 [...logs.commits].reverse().forEach((commitLog) => { 109 logger.log(' ', Formatter.formatCommitLog(commitLog)); 110 }); 111 } 112 if (logs && logs.files.length > 0) { 113 logger.log(' ', magenta('File changes:'), gray('(build folder not displayed)')); 114 115 logs.files.forEach((fileLog) => { 116 if (fileLog.relativePath.startsWith('build/')) { 117 return; 118 } 119 logger.log(' ', Formatter.formatFileLog(fileLog)); 120 }); 121 } 122 123 const unpublishedChanges = changelogChanges?.versions[Changelogs.UNPUBLISHED_VERSION_NAME] ?? {}; 124 125 for (const changeType in unpublishedChanges) { 126 const changes = unpublishedChanges[changeType]; 127 128 if (changes.length > 0) { 129 logger.log(' ', magenta(`${Formatter.stripNonAsciiChars(changeType).trim()}:`)); 130 131 for (const change of unpublishedChanges[changeType]) { 132 logger.log(' ', Formatter.formatChangelogEntry(change.message)); 133 } 134 } 135 } 136 137 if (pkgView && releaseType && releaseVersion) { 138 logger.log( 139 ' ', 140 magenta(`Suggested ${cyan.bold(releaseType)} upgrade to ${cyan.bold(releaseVersion)}`) 141 ); 142 } 143} 144 145/** 146 * Gets lists of commits and files changed under given directory and since commit with given checksum. 147 * Returned files list is filtered out from files ignored by npm when it creates package's tarball. 148 * Can return `null` if given commit is not an ancestor of head commit. 149 */ 150export async function getPackageGitLogsAsync( 151 gitDir: GitDirectory, 152 fromCommit?: string 153): Promise<PackageGitLogs> { 154 if (!fromCommit || !(await gitDir.isAncestorAsync(fromCommit))) { 155 return null; 156 } 157 158 const commits = await gitDir.logAsync({ 159 fromCommit, 160 toCommit: 'HEAD', 161 }); 162 163 const gitFiles = await gitDir.logFilesAsync({ 164 fromCommit, 165 toCommit: commits[0]?.hash, 166 }); 167 168 // Get an array of relative paths to files that will be shipped with the package. 169 const packlist = await npmPacklist({ path: gitDir.path }); 170 171 // Filter git files to contain only deleted or "packlisted" files. 172 const files = gitFiles.filter( 173 (file) => file.status === GitFileStatus.D || packlist.includes(file.relativePath) 174 ); 175 176 return { 177 commits, 178 files, 179 }; 180} 181 182export async function getMinReleaseTypeAsync( 183 pkg: Package, 184 logs: PackageGitLogs, 185 changelogChanges: any 186): Promise<ReleaseType> { 187 const unpublishedChanges = changelogChanges?.versions[Changelogs.UNPUBLISHED_VERSION_NAME]; 188 const hasBreakingChanges = unpublishedChanges?.[Changelogs.ChangeType.BREAKING_CHANGES]?.length; 189 const hasNewFeatures = unpublishedChanges?.[Changelogs.ChangeType.NEW_FEATURES]?.length; 190 191 // For breaking changes and new features we follow semver. 192 if (hasBreakingChanges) { 193 return ReleaseType.MAJOR; 194 } 195 if (hasNewFeatures) { 196 return ReleaseType.MINOR; 197 } 198 199 // If the package is a native module, then we have to check whether there are any native changes. 200 if (await pkg.isNativeModuleAsync()) { 201 const hasNativeChanges = logs && fileLogsContainNativeChanges(logs.files); 202 return hasNativeChanges ? ReleaseType.MINOR : ReleaseType.PATCH; 203 } 204 return ReleaseType.PATCH; 205} 206 207/** 208 * Returns suggested version based on given current version, already published versions and suggested release type. 209 */ 210export function resolveSuggestedVersion( 211 versionToBump: string, 212 otherVersions: string[], 213 releaseType: ReleaseType, 214 prereleaseIdentifier?: string | null 215): string { 216 const targetPrereleaseIdentifier = prereleaseIdentifier ?? getPrereleaseIdentifier(versionToBump); 217 218 // Higher version might have already been published from another place, 219 // so get the highest published version that satisfies release type. 220 const highestSatisfyingVersion = otherVersions 221 .filter((version) => { 222 return ( 223 semver.gt(version, versionToBump) && 224 semver.diff(version, versionToBump) === releaseType && 225 getPrereleaseIdentifier(version) === targetPrereleaseIdentifier 226 ); 227 }) 228 .sort(semver.rcompare)[0]; 229 230 return semver.inc( 231 highestSatisfyingVersion ?? versionToBump, 232 releaseType, 233 targetPrereleaseIdentifier ?? undefined 234 ) as string; 235} 236 237export function resolveReleaseTypeAndVersion(parcel: Parcel, options: CommandOptions) { 238 const prerelease = options.prerelease === true ? 'rc' : options.prerelease || undefined; 239 const { pkg, pkgView, state } = parcel; 240 241 // Find the highest release type among parcel's dependencies. 242 const accumulatedTypes = recursivelyAccumulateReleaseTypes(parcel); 243 const highestReleaseType = [...accumulatedTypes].reduce( 244 highestReleaseTypeReducer, 245 ReleaseType.PATCH 246 ); 247 const allVersions = pkgView?.versions ?? []; 248 249 if (prerelease) { 250 // Make it a prerelease version if `--prerelease` was passed and assign to the state. 251 state.releaseType = ('pre' + highestReleaseType) as ReleaseType; 252 } else if (getPrereleaseIdentifier(pkg.packageVersion)) { 253 // If the current version is a prerelease, just increment its number. 254 state.releaseType = ReleaseType.PRERELEASE; 255 } else { 256 // Set the release type depending on changes made in the package. 257 state.releaseType = highestReleaseType; 258 } 259 260 // If the version to bump is not published yet, then we do want to use it instead, 261 // no matter which release type is suggested. 262 if (allVersions.includes(pkg.packageVersion)) { 263 // Calculate version that we should bump to. 264 state.releaseVersion = resolveSuggestedVersion( 265 pkg.packageVersion, 266 allVersions, 267 state.releaseType, 268 prerelease 269 ); 270 } else { 271 state.releaseVersion = pkg.packageVersion; 272 } 273 return state.releaseVersion; 274} 275 276/** 277 * Accumulates all `minReleaseType` in given parcel and all its dependencies. 278 */ 279export function recursivelyAccumulateReleaseTypes( 280 parcel: Parcel, 281 set: Set<ReleaseType> = new Set() 282) { 283 if (parcel.minReleaseType) { 284 set.add(parcel.minReleaseType); 285 } 286 for (const dependency of parcel.dependencies) { 287 recursivelyAccumulateReleaseTypes(dependency, set); 288 } 289 return set; 290} 291 292/** 293 * Determines whether git file logs contain any changes in directories with native code. 294 */ 295function fileLogsContainNativeChanges(fileLogs: GitFileLog[]): boolean { 296 return fileLogs.some((fileLog) => { 297 return NATIVE_DIRECTORIES.some((dir) => fileLog.relativePath.startsWith(`${dir}/`)); 298 }); 299} 300 301export function isParcelUnpublished(parcel: Parcel): boolean { 302 const { logs, changelogChanges, dependencies } = parcel; 303 const hasChangedFiles = !logs || logs.files.length > 0; 304 const hasChangelogChanges = changelogChanges ? changelogChanges.totalCount > 0 : false; 305 306 return hasChangedFiles || hasChangelogChanges || dependencies.size > 0; 307} 308 309/** 310 * Used as a reducer to find the highest release type. 311 */ 312export function highestReleaseTypeReducer(a: ReleaseType, b: ReleaseType): ReleaseType { 313 const ai = RELEASE_TYPES_ASC_ORDER.indexOf(a); 314 const bi = RELEASE_TYPES_ASC_ORDER.indexOf(b); 315 return bi > ai ? b : a; 316} 317 318/** 319 * Returns prerelease identifier of given version or `null` if given version is not a prerelease version. 320 * `semver.prerelease` returns an array of prerelease parts (`1.0.0-beta.0` results in `['beta', 0]`), 321 * however we just need the identifier. 322 */ 323export function getPrereleaseIdentifier(version: string): string | null { 324 const prerelease = semver.prerelease(version); 325 return Array.isArray(prerelease) && typeof prerelease[0] === 'string' ? prerelease[0] : null; 326} 327 328/** 329 * Returns a list of suggested versions to publish. 330 */ 331export function getSuggestedVersions( 332 version: string, 333 otherVersions: string[], 334 prerelease?: string | null 335): string[] { 336 const [currentPrereleaseId] = semver.prerelease(version) ?? []; 337 338 // The current version is a prerelease version 339 if (typeof currentPrereleaseId === 'string') { 340 const prereleaseIds = ['alpha', 'beta', 'rc']; 341 342 if (!prereleaseIds.includes(currentPrereleaseId)) { 343 prereleaseIds.unshift(currentPrereleaseId); 344 } 345 return prereleaseIds 346 .slice(prereleaseIds.indexOf(currentPrereleaseId)) 347 .map((identifier) => { 348 return resolveSuggestedVersion(version, otherVersions, ReleaseType.PRERELEASE, identifier); 349 }) 350 .concat(version.replace(/\-.*$/, '')); 351 } 352 return [ReleaseType.MAJOR, ReleaseType.MINOR, ReleaseType.PATCH].map((type) => { 353 return resolveSuggestedVersion(version, otherVersions, type, prerelease); 354 }); 355} 356 357/** 358 * Returns a function that validates the version for given parcel. 359 */ 360export function validateVersion(parcel: Parcel) { 361 return (input: string) => { 362 if (input) { 363 if (!semver.valid(input)) { 364 return red(`${cyan.bold(input)} is not a valid semver version.`); 365 } 366 if (parcel.pkgView && parcel.pkgView.versions.includes(input)) { 367 return red(`${cyan.bold(input)} has already been published.`); 368 } 369 } 370 return true; 371 }; 372} 373