Skip to content

Commit

Permalink
[et] Add promote-packages expotools command (#8322)
Browse files Browse the repository at this point in the history
  • Loading branch information
tsapeta committed May 22, 2020
1 parent 2b2eeaf commit 41580b3
Show file tree
Hide file tree
Showing 8 changed files with 403 additions and 0 deletions.
69 changes: 69 additions & 0 deletions tools/expotools/src/commands/PromotePackages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Command } from '@expo/commander';

import { TaskRunner, Task } from '../TasksRunner';
import { CommandOptions, TaskArgs } from '../promote-packages/types';

import { promotePackages } from '../promote-packages/tasks/promotePackages';
import { listPackagesToPromote } from '../promote-packages/tasks/listPackagesToPromote';

export default (program: Command) => {
program
.command('promote-packages [packageNames...]')
.alias('promote-pkgs')
.option(
'-e, --exclude <packageName>',
'Name of the package to be excluded from promoting. Can be passed multiple times to exclude more than one package. It has higher priority than the list of package names to promote.',
(value, previous) => previous.concat(value),
[]
)
.option(
'-t, --tag <tag>',
'Tag to which packages should be promoted. Defaults to `latest`.',
'latest'
)
.option(
'--no-select',
'With this flag the script will not prompt to select packages, they all will be selected by default.',
false
)
.option(
'--no-drop',
'Without this flag, existing tags for the local version would be dropped after all.',
false
)
.option(
'-d, --demote',
'Enables tag demoting. If passed, the tag can be overriden even if its current version is higher than locally.',
false
)
.option(
'-l, --list',
'Lists packages with unpublished changes since the previous version.',
false
)

/* debug */
.option('-D, --dry', 'Whether to skip `npm dist-tag add` command.', false)

.description('Promotes local versions of monorepo packages to given tag on NPM repository.')
.asyncAction(async (packageNames: string[], options: CommandOptions) => {
// Commander doesn't put arguments to options object, let's add it for convenience. In fact, this is an option.
options.packageNames = packageNames;

const taskRunner = new TaskRunner<TaskArgs>({
tasks: tasksForOptions(options),
});

await taskRunner.runAndExitAsync([], options);
});
};

/**
* Returns target task instances based on provided command options.
*/
function tasksForOptions(options: CommandOptions): Task<TaskArgs>[] {
if (options.list) {
return [listPackagesToPromote];
}
return [promotePackages];
}
65 changes: 65 additions & 0 deletions tools/expotools/src/promote-packages/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import chalk from 'chalk';

import { Parcel } from './types';
import * as Changelogs from '../Changelogs';
import { GitDirectory } from '../Git';
import logger from '../Logger';
import { Package } from '../Packages';

const { cyan, green, magenta, red, gray } = chalk;

/**
* Wraps `Package` object into a parcels - convenient wrapper providing more package-related helpers.
*/
export async function createParcelAsync(pkg: Package): Promise<Parcel> {
return {
pkg,
pkgView: await pkg.getPackageViewAsync(),
changelog: Changelogs.loadFrom(pkg.changelogPath),
gitDir: new GitDirectory(pkg.path),
state: {},
};
}

/**
* Formats version change from version A to version B.
*/
export function formatVersionChange(
fromVersion: string | null | undefined,
toVersion: string
): string {
const from = fromVersion ? cyan.bold(fromVersion) : gray.bold('none');
const to = cyan.bold(toVersion);
return `from ${from} to ${to}`;
}

/**
* Prints a lists of packages to promote or demote.
*/
export function printPackagesToPromote(parcels: Parcel[]): void {
const toPromote = parcels.filter(({ state }) => !state.isDemoting);
const toDemote = parcels.filter(({ state }) => state.isDemoting);

printPackagesToPromoteInternal(
toPromote,
`Following packages would be ${green.bold('promoted')}:`
);
printPackagesToPromoteInternal(
toDemote,
`Following packages could be ${red.bold('demoted')} ${gray(`(requires --demote flag)`)}:`
);
}

function printPackagesToPromoteInternal(parcels: Parcel[], headerText: string): void {
if (parcels.length > 0) {
logger.log(' ', magenta(headerText));

for (const { pkg, state } of parcels) {
logger.log(
' ',
green(pkg.packageName),
formatVersionChange(state.versionToReplace, pkg.packageVersion)
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import semver from 'semver';

import logger from '../../Logger';
import { Task } from '../../TasksRunner';
import { CommandOptions, Parcel, TaskArgs } from '../types';

/**
* Finds packages whose local version is not tagged as the target tag provided as a command option (defaults to `latest`).
*/
export const findPackagesToPromote = new Task<TaskArgs>(
{
name: 'findPackagesToPromote',
},
async (parcels: Parcel[], options: CommandOptions): Promise<symbol | TaskArgs> => {
logger.info('\n👀 Searching for packages to promote...');

const newParcels: Parcel[] = [];

await Promise.all(
parcels.map(async (parcel) => {
const { pkg, pkgView, state } = parcel;
const currentDistTags = await pkg.getDistTagsAsync();
const versionToReplace = pkgView?.['dist-tags']?.[options.tag] ?? null;
const canPromote = pkgView && !currentDistTags.includes(options.tag);

state.distTags = currentDistTags;
state.versionToReplace = versionToReplace;
state.isDemoting = !!versionToReplace && semver.lt(pkg.packageVersion, versionToReplace);

if (canPromote && (!state.isDemoting || options.list || options.demote)) {
newParcels.push(parcel);
}
})
);

if (newParcels.length === 0) {
logger.success('\n✅ No packages to promote.\n');
return Task.STOP;
}
return [newParcels, options];
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import chalk from 'chalk';

import logger from '../../Logger';
import { Task } from '../../TasksRunner';
import { printPackagesToPromote } from '../helpers';
import { CommandOptions, Parcel, TaskArgs } from '../types';
import { findPackagesToPromote } from './findPackagesToPromote';
import { prepareParcels } from './prepareParcels';

const { yellow } = chalk;

/**
* Lists packages that can be promoted to given tag.
*/
export const listPackagesToPromote = new Task<TaskArgs>(
{
name: 'listPackagesToPromote',
dependsOn: [prepareParcels, findPackagesToPromote],
},
async (parcels: Parcel[], options: CommandOptions): Promise<void | symbol> => {
if (parcels.length === 0) {
logger.success(`\n✅ No packages to promote.\n`);
return Task.STOP;
}

logger.info(`\n📚 Packages to promote to ${yellow.bold(options.tag)}:`);
printPackagesToPromote(parcels);
}
);
30 changes: 30 additions & 0 deletions tools/expotools/src/promote-packages/tasks/prepareParcels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import logger from '../../Logger';
import { getListOfPackagesAsync } from '../../Packages';
import { Task } from '../../TasksRunner';
import { CommandOptions, Parcel, TaskArgs } from '../types';
import { createParcelAsync } from '../helpers';

/**
* Gets a list of public packages in the monorepo, downloads `npm view` result of them,
* creates their Changelog instance and fills in given parcels array (it's empty at the beginning).
*/
export const prepareParcels = new Task<TaskArgs>(
{
name: 'prepareParcels',
},
async (parcels: Parcel[], options: CommandOptions) => {
logger.info('🔎 Gathering data about packages...');

const { exclude, packageNames } = options;
const allPackages = await getListOfPackagesAsync();
const filteredPackages = allPackages.filter((pkg) => {
const isPrivate = pkg.packageJson.private;
const isIncluded = packageNames.length === 0 || packageNames.includes(pkg.packageName);
const isExcluded = exclude.includes(pkg.packageName);

return !isPrivate && isIncluded && !isExcluded;
});

parcels.push(...(await Promise.all(filteredPackages.map(createParcelAsync))));
}
);
61 changes: 61 additions & 0 deletions tools/expotools/src/promote-packages/tasks/promotePackages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import chalk from 'chalk';

import logger from '../../Logger';
import * as Npm from '../../Npm';
import { Task } from '../../TasksRunner';
import { formatVersionChange } from '../helpers';
import { CommandOptions, Parcel, TaskArgs } from '../types';
import { findPackagesToPromote } from './findPackagesToPromote';
import { prepareParcels } from './prepareParcels';
import { selectPackagesToPromote } from './selectPackagesToPromote';

const { yellow, red, green, cyan } = chalk;

/**
* Promotes local versions of selected packages to npm tag passed as an option.
*/
export const promotePackages = new Task<TaskArgs>(
{
name: 'promotePackages',
dependsOn: [prepareParcels, findPackagesToPromote, selectPackagesToPromote],
},
async (parcels: Parcel[], options: CommandOptions): Promise<void> => {
logger.info(`\n🚀 Promoting packages to ${yellow.bold(options.tag)} tag...`);

await Promise.all(
parcels.map(async ({ pkg, state }) => {
const currentVersion = pkg.packageVersion;
const { versionToReplace } = state;

const batch = logger.batch();
const action = state.isDemoting ? red('Demoting') : green('Promoting');
batch.log(' ', green.bold(pkg.packageName));
batch.log(
' ',
action,
yellow(options.tag),
formatVersionChange(versionToReplace, currentVersion)
);

// Tag the local version of the package.
if (!options.dry) {
await Npm.addTagAsync(pkg.packageName, pkg.packageVersion, options.tag);
}

// If the local version had any tags assigned, we can drop the old ones.
if (options.drop && state.distTags && !state.distTags.includes(options.tag)) {
for (const distTag of state.distTags) {
batch.log(' ', `Dropping ${yellow(distTag)} tag (${cyan(currentVersion)})...`);

if (!options.dry) {
await Npm.removeTagAsync(pkg.packageName, distTag);
}
}
}
batch.flush();
})
);

logger.success(`\n✅ Successfully promoted ${cyan(parcels.length + '')} packages.`);
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import chalk from 'chalk';
import inquirer from 'inquirer';
import readline from 'readline';
import stripAnsi from 'strip-ansi';

import logger from '../../Logger';
import { Task } from '../../TasksRunner';
import { formatVersionChange } from '../helpers';
import { CommandOptions, Parcel, TaskArgs } from '../types';
import { findPackagesToPromote } from './findPackagesToPromote';

const { green, red } = chalk;

/**
* Prompts the user to select packages to promote or demote.
* It's skipped if `--no-select` option is used or it's run on the CI.
*/
export const selectPackagesToPromote = new Task<TaskArgs>(
{
name: 'selectPackagesToPromote',
dependsOn: [findPackagesToPromote],
},
async (parcels: Parcel[], options: CommandOptions): Promise<void | TaskArgs> => {
if (!options.select || process.env.CI) {
return [parcels, options];
}

logger.info('\n👉 Selecting packages to promote...\n');

const packageNames = await promptForPackagesToPromoteAsync(parcels);
const newParcels = parcels.filter(({ pkg }) => packageNames.includes(pkg.packageName));

return [newParcels, options];
}
);

/**
* Prompts the user to select packages to promote or demote.
*/
async function promptForPackagesToPromoteAsync(parcels: Parcel[]): Promise<string[]> {
const maxLength = parcels.reduce((acc, { pkg }) => Math.max(acc, pkg.packageName.length), 0);
const choices = parcels.map(({ pkg, state }) => {
const action = state.isDemoting ? red.bold('demote') : green.bold('promote');

return {
name: `${green(pkg.packageName.padEnd(maxLength))} ${action} ${formatVersionChange(
state.versionToReplace,
pkg.packageVersion
)}`,
value: pkg.packageName,
checked: !state.isDemoting,
};
});
const { selectedPackageNames } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedPackageNames',
message: 'Which packages do you want to promote?\n',
choices: [
// Choices unchecked by default (these being demoted) should be on top.
// We could sort them, but JS sorting algorithm is unstable :/
...choices.filter((choice) => !choice.checked),
...choices.filter((choice) => choice.checked),
],
pageSize: Math.max(15, (process.stdout.rows ?? 15) - 15),
},
]);
// Inquirer shows all those selected choices by name and that looks so ugly due to line wrapping.
// If possible, we clear everything that has been printed after the prompt.
if (process.stdout.columns) {
const bufferLength = choices.reduce(
(acc, choice) => acc + stripAnsi(choice.name).length + 2,
0
);
readline.moveCursor(process.stdout, 0, -Math.ceil(bufferLength / process.stdout.columns));
readline.clearScreenDown(process.stdout);
}
return selectedPackageNames;
}

0 comments on commit 41580b3

Please sign in to comment.